diff --git a/cmd/cloudstic/cmd_tui_activity.go b/cmd/cloudstic/cmd_tui_activity.go index edf0aad..695b887 100644 --- a/cmd/cloudstic/cmd_tui_activity.go +++ b/cmd/cloudstic/cmd_tui_activity.go @@ -62,6 +62,43 @@ func runTUIActionIntoDashboard(ctx context.Context, r *runner, profilesFile stri return dashboard } +func runTUICheckIntoDashboard(ctx context.Context, r *runner, profilesFile string, dashboard tui.Dashboard) tui.Dashboard { + log := newTUIActionState(10) + screen := r.out + if profile, ok := selectedTUIProfile(dashboard); ok { + log.Printf("Running repository check for profile %s", profile.Name) + } + + stop := make(chan struct{}) + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + defer close(done) + for { + select { + case <-stop: + return + case <-ticker.C: + live := dashboard + live.ActivityLines = log.Lines() + _ = renderTUIScreenWidth(screen, live, tuiWidth(r)) + } + } + }() + + if err := runSelectedTUICheck(ctx, r, profilesFile, dashboard, log); err != nil { + log.Printf("Check failed: %v", err) + } else { + log.Printf("Check completed successfully") + } + close(stop) + <-done + + dashboard.ActivityLines = mergeTUIActivityLines(dashboard.ActivityLines, log.Lines()) + return dashboard +} + func mergeTUIActivityLines(existing, recent []string) []string { merged := append([]string{}, recent...) merged = append(merged, existing...) diff --git a/cmd/cloudstic/cmd_tui_input.go b/cmd/cloudstic/cmd_tui_input.go index 8eb5923..bfe758d 100644 --- a/cmd/cloudstic/cmd_tui_input.go +++ b/cmd/cloudstic/cmd_tui_input.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "os" - "strings" "github.com/cloudstic/cli/internal/tui" ) @@ -17,6 +16,7 @@ const ( tuiActionUp tuiActionDown tuiActionRun + tuiActionCheck tuiActionQuit ) @@ -79,6 +79,8 @@ func readTUIAction(r io.ByteReader) (tuiAction, error) { return tuiActionUp, nil case 'b', 'B': return tuiActionRun, nil + case 'c', 'C': + return tuiActionCheck, nil case 0x1b: next, err := r.ReadByte() if err != nil { @@ -143,6 +145,14 @@ func runSelectedTUIAction(ctx context.Context, r *runner, profilesFile string, d return tuiRunProfileAction(ctx, r, profilesFile, profile, log) } +func runSelectedTUICheck(ctx context.Context, r *runner, profilesFile string, dashboard tui.Dashboard, log *tuiActionState) error { + profile, ok := selectedTUIProfile(dashboard) + if !ok { + return fmt.Errorf("no profile selected") + } + return tuiRunProfileCheck(ctx, r, profilesFile, profile, log) +} + func selectedTUIProfile(d tui.Dashboard) (tui.ProfileCard, bool) { for _, profile := range d.Profiles { if profile.Name == d.SelectedProfile { @@ -156,5 +166,5 @@ func selectedTUIProfile(d tui.Dashboard) (tui.ProfileCard, bool) { } func profileNeedsInit(profile tui.ProfileCard) bool { - return strings.Contains(profile.StatusNote, "repository not initialized") + return profile.StoreHealth == tui.StoreHealthNotInitialized } diff --git a/cmd/cloudstic/cmd_tui_test.go b/cmd/cloudstic/cmd_tui_test.go index 784d333..48a1528 100644 --- a/cmd/cloudstic/cmd_tui_test.go +++ b/cmd/cloudstic/cmd_tui_test.go @@ -138,7 +138,7 @@ func TestRunTUI_RendersDashboardAndQuitsOnQ(t *testing.T) { Source: "local:/tmp/Documents", StoreRef: "remote", Enabled: true, - Status: "ready", + Status: tui.ProfileStatusReady, LastBackup: "2026-04-03 11:05", LastRef: "snapshot/abc123", }}, @@ -187,8 +187,8 @@ func TestRunTUI_ArrowNavigationChangesSelection(t *testing.T) { ProfileCount: 2, StoreCount: 1, Profiles: []tui.ProfileCard{ - {Name: "documents", Source: "local:/tmp/Documents", StoreRef: "remote", Enabled: true, Status: "ready"}, - {Name: "photos", Source: "local:/tmp/Photos", StoreRef: "remote", Enabled: true, Status: "ready"}, + {Name: "documents", Source: "local:/tmp/Documents", StoreRef: "remote", Enabled: true, Status: tui.ProfileStatusReady}, + {Name: "photos", Source: "local:/tmp/Photos", StoreRef: "remote", Enabled: true, Status: tui.ProfileStatusReady}, }, }, nil } @@ -230,9 +230,11 @@ func TestRunTUI_BackupActionRunsSelectedProfileAction(t *testing.T) { var ranProfile string oldBuild := tuiBuildDashboard oldAction := tuiRunProfileAction + oldCheck := tuiRunProfileCheck t.Cleanup(func() { tuiBuildDashboard = oldBuild tuiRunProfileAction = oldAction + tuiRunProfileCheck = oldCheck }) tuiBuildDashboard = func(context.Context, string) (tui.Dashboard, error) { return tui.Dashboard{ @@ -240,7 +242,7 @@ func TestRunTUI_BackupActionRunsSelectedProfileAction(t *testing.T) { StoreCount: 1, SelectedProfile: "documents", Profiles: []tui.ProfileCard{ - {Name: "documents", Source: "local:/tmp/Documents", StoreRef: "remote", Enabled: true, Status: "ready"}, + {Name: "documents", Source: "local:/tmp/Documents", StoreRef: "remote", Enabled: true, Status: tui.ProfileStatusReady}, }, }, nil } @@ -272,6 +274,73 @@ func TestRunTUI_BackupActionRunsSelectedProfileAction(t *testing.T) { } } +func TestRunTUI_CheckActionRunsSelectedProfileCheck(t *testing.T) { + stubTUITestHooks(t) + + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = []string{"cloudstic", "tui"} + + readEnd, writeEnd, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + defer func() { _ = readEnd.Close() }() + if _, err := writeEnd.WriteString("cq"); err != nil { + t.Fatalf("WriteString: %v", err) + } + _ = writeEnd.Close() + + var out strings.Builder + var errOut strings.Builder + var checkedProfile string + oldBuild := tuiBuildDashboard + oldAction := tuiRunProfileAction + oldCheck := tuiRunProfileCheck + t.Cleanup(func() { + tuiBuildDashboard = oldBuild + tuiRunProfileAction = oldAction + tuiRunProfileCheck = oldCheck + }) + tuiBuildDashboard = func(context.Context, string) (tui.Dashboard, error) { + return tui.Dashboard{ + ProfileCount: 1, + StoreCount: 1, + SelectedProfile: "documents", + Profiles: []tui.ProfileCard{ + {Name: "documents", Source: "local:/tmp/Documents", StoreRef: "remote", Enabled: true, Status: tui.ProfileStatusReady, StoreHealth: tui.StoreHealthReady}, + }, + }, nil + } + tuiRunProfileAction = func(_ context.Context, _ *runner, _ string, _ tui.ProfileCard, _ *tuiActionState) error { + t.Fatalf("backup action should not run") + return nil + } + tuiRunProfileCheck = func(_ context.Context, _ *runner, _ string, profile tui.ProfileCard, _ *tuiActionState) error { + checkedProfile = profile.Name + return nil + } + r := &runner{ + out: &out, + errOut: &errOut, + stdoutFile: os.Stdout, + stdin: readEnd, + lineIn: bufio.NewReader(readEnd), + } + if code := r.runTUI(context.Background()); code != 0 { + t.Fatalf("code=%d err=%s", code, errOut.String()) + } + if checkedProfile != "documents" { + t.Fatalf("selected check ran for %q, want documents", checkedProfile) + } + if !strings.Contains(out.String(), "Running repository check for profile documents") { + t.Fatalf("expected check activity log in dashboard, got:\n%s", out.String()) + } + if !strings.Contains(out.String(), "Check completed successfully") { + t.Fatalf("expected check success log in dashboard, got:\n%s", out.String()) + } +} + func TestReadTUIAction_ParsesCSIArrowKeys(t *testing.T) { ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[A"))) if err != nil { @@ -326,6 +395,16 @@ func TestReadTUIAction_ParsesSS3ArrowKeys(t *testing.T) { } } +func TestReadTUIAction_ParsesCheckShortcut(t *testing.T) { + ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("c"))) + if err != nil { + t.Fatalf("readTUIAction check: %v", err) + } + if ev != tuiActionCheck { + t.Fatalf("check action=%v want %v", ev, tuiActionCheck) + } +} + func TestTUISession_EnterLeaveManagesTerminalState(t *testing.T) { readEnd, writeEnd, err := os.Pipe() if err != nil { @@ -386,7 +465,7 @@ func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) { StoreCount: 1, SelectedProfile: "docs", Profiles: []tui.ProfileCard{ - {Name: "docs", Source: "local:/docs", StoreRef: "remote", Enabled: true, Status: "ready", LastBackup: "2026-04-03 12:00"}, + {Name: "docs", Source: "local:/docs", StoreRef: "remote", Enabled: true, Status: tui.ProfileStatusReady, LastBackup: "2026-04-03 12:00"}, }, }, nil } @@ -399,7 +478,7 @@ func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) { s := newTUISession(&runner{out: &out, stdoutFile: os.Stdout, stdin: os.Stdin}, "profiles.yaml", tui.Dashboard{ SelectedProfile: "docs", Profiles: []tui.ProfileCard{ - {Name: "docs", Source: "local:/docs", StoreRef: "remote", Enabled: true, Status: "ready"}, + {Name: "docs", Source: "local:/docs", StoreRef: "remote", Enabled: true, Status: tui.ProfileStatusReady}, }, }) @@ -423,7 +502,7 @@ func TestTUISession_RefreshPreservesSelectionAndActivity(t *testing.T) { tuiBuildDashboard = func(context.Context, string) (tui.Dashboard, error) { return tui.Dashboard{ Profiles: []tui.ProfileCard{ - {Name: "docs", Source: "local:/docs", StoreRef: "remote", Enabled: true, Status: "ready"}, + {Name: "docs", Source: "local:/docs", StoreRef: "remote", Enabled: true, Status: tui.ProfileStatusReady}, }, }, nil } @@ -444,27 +523,6 @@ func TestTUISession_RefreshPreservesSelectionAndActivity(t *testing.T) { } } -func TestRunTUIInitAction_MissingStore(t *testing.T) { - err := runTUIInitAction(context.Background(), &runner{}, "profiles.yaml", "docs", cloudstic.BackupProfile{Store: "missing"}, &cloudstic.ProfilesConfig{}) - if err == nil || !strings.Contains(err.Error(), `references unknown store "missing"`) { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestLoadTUIProfilesConfig_InitializesMaps(t *testing.T) { - path := t.TempDir() + "/profiles.yaml" - if err := cloudstic.SaveProfilesFile(path, &cloudstic.ProfilesConfig{Version: 1}); err != nil { - t.Fatalf("SaveProfilesFile: %v", err) - } - cfg, err := loadTUIProfilesConfig(path) - if err != nil { - t.Fatalf("loadTUIProfilesConfig: %v", err) - } - if cfg.Profiles == nil || cfg.Stores == nil || cfg.Auth == nil { - t.Fatalf("expected maps to be initialized: %+v", cfg) - } -} - func TestCaptureTUIRunnerOutput_RestoresRunnerState(t *testing.T) { var out strings.Builder var errOut strings.Builder diff --git a/cmd/cloudstic/tui_runtime.go b/cmd/cloudstic/tui_runtime.go index 672e7cd..b5846ac 100644 --- a/cmd/cloudstic/tui_runtime.go +++ b/cmd/cloudstic/tui_runtime.go @@ -7,14 +7,17 @@ import ( "os" cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/app" "github.com/cloudstic/cli/internal/engine" "github.com/cloudstic/cli/internal/tui" xterm "golang.org/x/term" ) var ( + tuiServiceFactory = defaultTUIServiceFactory tuiBuildDashboard = defaultBuildTUIDashboard tuiRunProfileAction = defaultRunTUIProfileAction + tuiRunProfileCheck = defaultRunTUIProfileCheck tuiMakeRaw = xterm.MakeRaw tuiRestoreTerminal = xterm.Restore tuiGetTerminalSize = xterm.GetSize @@ -22,77 +25,38 @@ var ( tuiLeaveAltScreen = defaultLeaveAltScreen ) -func defaultEnterAltScreen(w io.Writer) error { - _, err := fmt.Fprint(w, "\x1b[?1049h\x1b[?1007h\x1b[2J\x1b[H\x1b[?25l") - return err -} - -func defaultLeaveAltScreen(w io.Writer) error { - _, err := fmt.Fprint(w, "\x1b[?25h\x1b[?1007l\x1b[?1049l") - return err -} - -func defaultBuildTUIDashboard(ctx context.Context, profilesFile string) (tui.Dashboard, error) { - cfg, err := loadTUIProfilesConfig(profilesFile) - if err != nil { - return tui.Dashboard{}, err - } - return tui.BuildDashboardFromConfig(ctx, cfg, func(ctx context.Context, storeName string, storeCfg cloudstic.ProfileStore) ([]engine.SnapshotEntry, error) { - g := tuiStoreFlags(profilesFile, storeCfg) - client, err := g.openClient(ctx) - if err != nil { - return nil, fmt.Errorf("%s: %w", storeName, err) - } - result, err := client.List(ctx) - if err != nil { - return nil, fmt.Errorf("%s: %w", storeName, err) - } - return result.Snapshots, nil - }), nil +type tuiCLIBackend struct { + r *runner + profilesFile string } -func defaultRunTUIProfileAction(ctx context.Context, r *runner, profilesFile string, profile tui.ProfileCard, log *tuiActionState) error { - restoreOutput := captureTUIRunnerOutput(r, log) - defer restoreOutput() - - cfg, err := loadTUIProfilesConfig(profilesFile) +func (b tuiCLIBackend) LoadStoreSnapshots(ctx context.Context, storeName string, storeCfg cloudstic.ProfileStore) ([]engine.SnapshotEntry, error) { + g := tuiStoreFlags(b.profilesFile, storeCfg) + client, err := g.openClient(ctx) if err != nil { - return fmt.Errorf("load profiles: %w", err) - } - profileCfg, ok := cfg.Profiles[profile.Name] - if !ok { - return fmt.Errorf("unknown profile %q", profile.Name) - } - - if profileNeedsInit(profile) { - return runTUIInitAction(ctx, r, profilesFile, profile.Name, profileCfg, cfg) + return nil, fmt.Errorf("%s: %w", storeName, err) } - return runTUIBackupAction(ctx, r, profilesFile, profile.Name, profileCfg, cfg, log) -} - -func loadTUIProfilesConfig(profilesFile string) (*cloudstic.ProfilesConfig, error) { - cfg, err := loadProfilesOrInit(profilesFile) + result, err := client.List(ctx) if err != nil { - return nil, err + return nil, fmt.Errorf("%s: %w", storeName, err) } - ensureProfilesMaps(cfg) - return cfg, nil + return result.Snapshots, nil } -func runTUIInitAction(ctx context.Context, r *runner, profilesFile, profileName string, profileCfg cloudstic.BackupProfile, cfg *cloudstic.ProfilesConfig) error { +func (b tuiCLIBackend) InitProfile(ctx context.Context, profilesFile, profileName string, profileCfg cloudstic.BackupProfile, cfg *cloudstic.ProfilesConfig) error { storeCfg, ok := cfg.Stores[profileCfg.Store] if !ok { return fmt.Errorf("profile %q references unknown store %q", profileName, profileCfg.Store) } g := tuiStoreFlags(profilesFile, storeCfg) *g.quiet = false - if code := r.runInitWithArgs(ctx, &initArgs{g: g}); code != 0 { + if code := b.r.runInitWithArgs(ctx, &initArgs{g: g}); code != 0 { return fmt.Errorf("init failed") } return nil } -func runTUIBackupAction(ctx context.Context, r *runner, profilesFile, profileName string, profileCfg cloudstic.BackupProfile, cfg *cloudstic.ProfilesConfig, log *tuiActionState) error { +func (b tuiCLIBackend) BackupProfile(ctx context.Context, profilesFile, profileName string, profileCfg cloudstic.BackupProfile, cfg *cloudstic.ProfilesConfig, reporter cloudstic.Reporter) error { base := &backupArgs{ g: tuiStoreFlags(profilesFile, cloudstic.ProfileStore{}), profile: profileName, @@ -104,18 +68,68 @@ func runTUIBackupAction(ctx context.Context, r *runner, profilesFile, profileNam if err != nil { return err } - client, err := effective.g.openClientWithReporter(ctx, log.Reporter()) + client, err := effective.g.openClientWithReporter(ctx, reporter) if err != nil { return fmt.Errorf("init store: %w", err) } - r.client = client - defer func() { r.client = nil }() - if code := r.runSingleBackup(effective); code != 0 { + b.r.client = client + defer func() { b.r.client = nil }() + if code := b.r.runSingleBackup(effective); code != 0 { return fmt.Errorf("backup failed") } return nil } +func (b tuiCLIBackend) CheckProfile(ctx context.Context, profilesFile, profileName string, profileCfg cloudstic.BackupProfile, cfg *cloudstic.ProfilesConfig, reporter cloudstic.Reporter) error { + storeCfg, ok := cfg.Stores[profileCfg.Store] + if !ok { + return fmt.Errorf("profile %q references unknown store %q", profileName, profileCfg.Store) + } + g := tuiStoreFlags(profilesFile, storeCfg) + client, err := g.openClientWithReporter(ctx, reporter) + if err != nil { + return fmt.Errorf("init store: %w", err) + } + result, err := client.Check(ctx, cloudstic.WithSnapshotRef("latest")) + if err != nil { + return fmt.Errorf("check failed: %w", err) + } + if b.r.printCheckResult(result) { + return fmt.Errorf("repository check reported errors") + } + return nil +} + +func defaultEnterAltScreen(w io.Writer) error { + _, err := fmt.Fprint(w, "\x1b[?1049h\x1b[?1007h\x1b[2J\x1b[H\x1b[?25l") + return err +} + +func defaultLeaveAltScreen(w io.Writer) error { + _, err := fmt.Fprint(w, "\x1b[?25h\x1b[?1007l\x1b[?1049l") + return err +} + +func defaultTUIServiceFactory(r *runner, profilesFile string, log *tuiActionState) *app.TUIService { + return app.NewTUIService(tuiCLIBackend{r: r, profilesFile: profilesFile}) +} + +func defaultBuildTUIDashboard(ctx context.Context, profilesFile string) (tui.Dashboard, error) { + return tuiServiceFactory(nil, profilesFile, nil).BuildDashboard(ctx, profilesFile) +} + +func defaultRunTUIProfileAction(ctx context.Context, r *runner, profilesFile string, profile tui.ProfileCard, log *tuiActionState) error { + restoreOutput := captureTUIRunnerOutput(r, log) + defer restoreOutput() + return tuiServiceFactory(r, profilesFile, log).RunProfileAction(ctx, profilesFile, profile, log.Reporter()) +} + +func defaultRunTUIProfileCheck(ctx context.Context, r *runner, profilesFile string, profile tui.ProfileCard, log *tuiActionState) error { + restoreOutput := captureTUIRunnerOutput(r, log) + defer restoreOutput() + return tuiServiceFactory(r, profilesFile, log).RunProfileCheck(ctx, profilesFile, profile, log.Reporter()) +} + type tuiSession struct { r *runner profilesFile string @@ -272,6 +286,17 @@ func (s *tuiSession) handleAction(ctx context.Context, action tuiAction) (int, e if err := s.resumeRaw(); err != nil { return -1, fmt.Errorf("failed to configure terminal: %v", err) } + case tuiActionCheck: + if err := s.suspendRaw(); err != nil { + return -1, fmt.Errorf("failed to configure terminal: %v", err) + } + s.dashboard = runTUICheckIntoDashboard(ctx, s.r, s.profilesFile, s.dashboard) + if err := s.refresh(ctx); err != nil { + return -1, fmt.Errorf("failed to refresh TUI dashboard: %v", err) + } + if err := s.resumeRaw(); err != nil { + return -1, fmt.Errorf("failed to configure terminal: %v", err) + } default: return -1, nil } diff --git a/docs/user-guide.md b/docs/user-guide.md index c3df782..ff094fe 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -607,6 +607,7 @@ Current controls: - `↑` / `↓` or `j` / `k`: move selection - `b`: run `backup` for the selected profile, or `init` if its store is not initialized +- `c`: run `check` for the selected profile's repository - `q`: quit `cloudstic tui` requires an interactive terminal. It is not intended for scripts or CI. diff --git a/internal/app/tui_service.go b/internal/app/tui_service.go new file mode 100644 index 0000000..584029c --- /dev/null +++ b/internal/app/tui_service.go @@ -0,0 +1,128 @@ +package app + +import ( + "context" + "errors" + "fmt" + "os" + + cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/engine" + "github.com/cloudstic/cli/internal/tui" +) + +type TUIBackend interface { + LoadStoreSnapshots(context.Context, string, cloudstic.ProfileStore) ([]engine.SnapshotEntry, error) + InitProfile(context.Context, string, string, cloudstic.BackupProfile, *cloudstic.ProfilesConfig) error + BackupProfile(context.Context, string, string, cloudstic.BackupProfile, *cloudstic.ProfilesConfig, cloudstic.Reporter) error + CheckProfile(context.Context, string, string, cloudstic.BackupProfile, *cloudstic.ProfilesConfig, cloudstic.Reporter) error +} + +type TUIService struct { + loadProfiles func(string) (*cloudstic.ProfilesConfig, error) + backend TUIBackend +} + +func NewTUIService(backend TUIBackend) *TUIService { + return &TUIService{ + loadProfiles: loadProfilesConfig, + backend: backend, + } +} + +func (s *TUIService) BuildDashboard(ctx context.Context, profilesFile string) (tui.Dashboard, error) { + cfg, err := s.loadConfig(profilesFile) + if err != nil { + return tui.Dashboard{}, err + } + var load tui.SnapshotLoader + if s.backend != nil { + load = func(ctx context.Context, storeName string, storeCfg cloudstic.ProfileStore) ([]engine.SnapshotEntry, error) { + return s.backend.LoadStoreSnapshots(ctx, storeName, storeCfg) + } + } + return tui.BuildDashboardFromConfig(ctx, cfg, load), nil +} + +func (s *TUIService) RunProfileAction(ctx context.Context, profilesFile string, profile tui.ProfileCard, reporter cloudstic.Reporter) error { + cfg, err := s.loadConfig(profilesFile) + if err != nil { + return fmt.Errorf("load profiles: %w", err) + } + + profileCfg, ok := cfg.Profiles[profile.Name] + if !ok { + return fmt.Errorf("unknown profile %q", profile.Name) + } + + if profileNeedsInit(profile) { + if s.backend == nil { + return fmt.Errorf("init action is not configured") + } + return s.backend.InitProfile(ctx, profilesFile, profile.Name, profileCfg, cfg) + } + + if s.backend == nil { + return fmt.Errorf("backup action is not configured") + } + return s.backend.BackupProfile(ctx, profilesFile, profile.Name, profileCfg, cfg, reporter) +} + +func (s *TUIService) RunProfileCheck(ctx context.Context, profilesFile string, profile tui.ProfileCard, reporter cloudstic.Reporter) error { + cfg, err := s.loadConfig(profilesFile) + if err != nil { + return fmt.Errorf("load profiles: %w", err) + } + + profileCfg, ok := cfg.Profiles[profile.Name] + if !ok { + return fmt.Errorf("unknown profile %q", profile.Name) + } + if profileNeedsInit(profile) { + return fmt.Errorf("repository is not initialized") + } + if s.backend == nil { + return fmt.Errorf("check action is not configured") + } + return s.backend.CheckProfile(ctx, profilesFile, profile.Name, profileCfg, cfg, reporter) +} + +func (s *TUIService) loadConfig(profilesFile string) (*cloudstic.ProfilesConfig, error) { + load := s.loadProfiles + if load == nil { + load = loadProfilesConfig + } + cfg, err := load(profilesFile) + if err != nil { + return nil, err + } + ensureProfilesMaps(cfg) + return cfg, nil +} + +func loadProfilesConfig(path string) (*cloudstic.ProfilesConfig, error) { + cfg, err := cloudstic.LoadProfilesFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &cloudstic.ProfilesConfig{Version: 1}, nil + } + return nil, err + } + return cfg, nil +} + +func ensureProfilesMaps(cfg *cloudstic.ProfilesConfig) { + if cfg.Stores == nil { + cfg.Stores = map[string]cloudstic.ProfileStore{} + } + if cfg.Profiles == nil { + cfg.Profiles = map[string]cloudstic.BackupProfile{} + } + if cfg.Auth == nil { + cfg.Auth = map[string]cloudstic.ProfileAuth{} + } +} + +func profileNeedsInit(profile tui.ProfileCard) bool { + return profile.StoreHealth == tui.StoreHealthNotInitialized +} diff --git a/internal/app/tui_service_test.go b/internal/app/tui_service_test.go new file mode 100644 index 0000000..7dd2a35 --- /dev/null +++ b/internal/app/tui_service_test.go @@ -0,0 +1,185 @@ +package app + +import ( + "context" + "errors" + "testing" + + cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/engine" + "github.com/cloudstic/cli/internal/tui" +) + +type stubTUIBackend struct { + loadStoreSnapshots func(context.Context, string, cloudstic.ProfileStore) ([]engine.SnapshotEntry, error) + initProfile func(context.Context, string, string, cloudstic.BackupProfile, *cloudstic.ProfilesConfig) error + backupProfile func(context.Context, string, string, cloudstic.BackupProfile, *cloudstic.ProfilesConfig, cloudstic.Reporter) error + checkProfile func(context.Context, string, string, cloudstic.BackupProfile, *cloudstic.ProfilesConfig, cloudstic.Reporter) error +} + +func (b stubTUIBackend) LoadStoreSnapshots(ctx context.Context, storeName string, storeCfg cloudstic.ProfileStore) ([]engine.SnapshotEntry, error) { + if b.loadStoreSnapshots == nil { + return nil, nil + } + return b.loadStoreSnapshots(ctx, storeName, storeCfg) +} + +func (b stubTUIBackend) InitProfile(ctx context.Context, profilesFile, profileName string, profileCfg cloudstic.BackupProfile, cfg *cloudstic.ProfilesConfig) error { + if b.initProfile == nil { + return nil + } + return b.initProfile(ctx, profilesFile, profileName, profileCfg, cfg) +} + +func (b stubTUIBackend) BackupProfile(ctx context.Context, profilesFile, profileName string, profileCfg cloudstic.BackupProfile, cfg *cloudstic.ProfilesConfig, reporter cloudstic.Reporter) error { + if b.backupProfile == nil { + return nil + } + return b.backupProfile(ctx, profilesFile, profileName, profileCfg, cfg, reporter) +} + +func (b stubTUIBackend) CheckProfile(ctx context.Context, profilesFile, profileName string, profileCfg cloudstic.BackupProfile, cfg *cloudstic.ProfilesConfig, reporter cloudstic.Reporter) error { + if b.checkProfile == nil { + return nil + } + return b.checkProfile(ctx, profilesFile, profileName, profileCfg, cfg, reporter) +} + +func TestTUIServiceBuildDashboardInitializesMaps(t *testing.T) { + svc := NewTUIService(nil) + svc.loadProfiles = func(string) (*cloudstic.ProfilesConfig, error) { + return &cloudstic.ProfilesConfig{Version: 1}, nil + } + + got, err := svc.BuildDashboard(context.Background(), "profiles.yaml") + if err != nil { + t.Fatalf("BuildDashboard: %v", err) + } + if got.ProfileCount != 0 || got.StoreCount != 0 || got.AuthCount != 0 { + t.Fatalf("unexpected dashboard: %+v", got) + } +} + +func TestTUIServiceRunProfileActionRunsInitWhenNeeded(t *testing.T) { + called := "" + svc := NewTUIService(stubTUIBackend{ + initProfile: func(context.Context, string, string, cloudstic.BackupProfile, *cloudstic.ProfilesConfig) error { + called = "init" + return nil + }, + backupProfile: func(context.Context, string, string, cloudstic.BackupProfile, *cloudstic.ProfilesConfig, cloudstic.Reporter) error { + called = "backup" + return nil + }, + }) + svc.loadProfiles = func(string) (*cloudstic.ProfilesConfig, error) { + return &cloudstic.ProfilesConfig{ + Version: 1, + Profiles: map[string]cloudstic.BackupProfile{ + "docs": {Source: "local:/docs", Store: "remote"}, + }, + }, nil + } + + err := svc.RunProfileAction(context.Background(), "profiles.yaml", tui.ProfileCard{ + Name: "docs", + StoreHealth: tui.StoreHealthNotInitialized, + }, nil) + if err != nil { + t.Fatalf("RunProfileAction: %v", err) + } + if called != "init" { + t.Fatalf("called %q want init", called) + } +} + +func TestTUIServiceRunProfileActionRunsBackup(t *testing.T) { + called := "" + svc := NewTUIService(stubTUIBackend{ + backupProfile: func(context.Context, string, string, cloudstic.BackupProfile, *cloudstic.ProfilesConfig, cloudstic.Reporter) error { + called = "backup" + return nil + }, + }) + svc.loadProfiles = func(string) (*cloudstic.ProfilesConfig, error) { + return &cloudstic.ProfilesConfig{ + Version: 1, + Profiles: map[string]cloudstic.BackupProfile{ + "docs": {Source: "local:/docs", Store: "remote"}, + }, + }, nil + } + + err := svc.RunProfileAction(context.Background(), "profiles.yaml", tui.ProfileCard{ + Name: "docs", + Status: tui.ProfileStatusReady, + }, nil) + if err != nil { + t.Fatalf("RunProfileAction: %v", err) + } + if called != "backup" { + t.Fatalf("called %q want backup", called) + } +} + +func TestTUIServiceRunProfileActionPropagatesLoadError(t *testing.T) { + svc := NewTUIService(nil) + svc.loadProfiles = func(string) (*cloudstic.ProfilesConfig, error) { + return nil, errors.New("boom") + } + + err := svc.RunProfileAction(context.Background(), "profiles.yaml", tui.ProfileCard{Name: "docs"}, nil) + if err == nil || err.Error() != "load profiles: boom" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTUIServiceRunProfileCheckRunsBackend(t *testing.T) { + called := "" + svc := NewTUIService(stubTUIBackend{ + checkProfile: func(context.Context, string, string, cloudstic.BackupProfile, *cloudstic.ProfilesConfig, cloudstic.Reporter) error { + called = "check" + return nil + }, + }) + svc.loadProfiles = func(string) (*cloudstic.ProfilesConfig, error) { + return &cloudstic.ProfilesConfig{ + Version: 1, + Profiles: map[string]cloudstic.BackupProfile{ + "docs": {Source: "local:/docs", Store: "remote"}, + }, + }, nil + } + + err := svc.RunProfileCheck(context.Background(), "profiles.yaml", tui.ProfileCard{ + Name: "docs", + Status: tui.ProfileStatusReady, + StoreHealth: tui.StoreHealthReady, + }, nil) + if err != nil { + t.Fatalf("RunProfileCheck: %v", err) + } + if called != "check" { + t.Fatalf("called %q want check", called) + } +} + +func TestTUIServiceRunProfileCheckRejectsUninitializedRepo(t *testing.T) { + svc := NewTUIService(stubTUIBackend{}) + svc.loadProfiles = func(string) (*cloudstic.ProfilesConfig, error) { + return &cloudstic.ProfilesConfig{ + Version: 1, + Profiles: map[string]cloudstic.BackupProfile{ + "docs": {Source: "local:/docs", Store: "remote"}, + }, + }, nil + } + + err := svc.RunProfileCheck(context.Background(), "profiles.yaml", tui.ProfileCard{ + Name: "docs", + StoreHealth: tui.StoreHealthNotInitialized, + }, nil) + if err == nil || err.Error() != "repository is not initialized" { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 55656f0..40c5ebc 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -20,16 +20,50 @@ type Dashboard struct { Profiles []ProfileCard } +type ProfileStatus string + +const ( + ProfileStatusReady ProfileStatus = "ready" + ProfileStatusDisabled ProfileStatus = "disabled" + ProfileStatusWarning ProfileStatus = "warning" + ProfileStatusError ProfileStatus = "error" +) + +type StoreHealth string + +const ( + StoreHealthReady StoreHealth = "ready" + StoreHealthPending StoreHealth = "pending" + StoreHealthDisabled StoreHealth = "disabled" + StoreHealthMissingStore StoreHealth = "missing_store" + StoreHealthMissingAuth StoreHealth = "missing_auth" + StoreHealthProviderMismatch StoreHealth = "provider_mismatch" + StoreHealthUnavailable StoreHealth = "unavailable" + StoreHealthNotInitialized StoreHealth = "not_initialized" + StoreHealthUnknown StoreHealth = "unknown" +) + +type BackupFreshness string + +const ( + BackupFreshnessUnknown BackupFreshness = "" + BackupFreshnessNever BackupFreshness = "never" + BackupFreshnessRecent BackupFreshness = "recent" + BackupFreshnessStale BackupFreshness = "stale" +) + type ProfileCard struct { - Name string - Source string - StoreRef string - AuthRef string - Enabled bool - Status string - StatusNote string - LastBackup string - LastRef string + Name string + Source string + StoreRef string + AuthRef string + Enabled bool + Status ProfileStatus + StatusNote string + StoreHealth StoreHealth + BackupState BackupFreshness + LastBackup string + LastRef string } type StoreProbe struct { @@ -89,72 +123,129 @@ func BuildDashboard(cfg *engine.ProfilesConfig, probes map[string]StoreProbe) Da for _, name := range names { profile := cfg.Profiles[name] status, note := profileStatus(cfg, profile, probes[profile.Store]) - lastBackup, lastRef := latestBackup(profile.Source, probes[profile.Store].Snapshots) + lastBackup, lastRef, lastCreated := latestBackup(profile.Source, probes[profile.Store].Snapshots) + storeHealth := deriveStoreHealth(cfg, profile, probes[profile.Store]) + backupState := deriveBackupState(lastCreated) + if lastBackup == "" { + backupState = BackupFreshnessNever + } d.Profiles = append(d.Profiles, ProfileCard{ - Name: name, - Source: profile.Source, - StoreRef: profile.Store, - AuthRef: profile.AuthRef, - Enabled: profile.IsEnabled(), - Status: status, - StatusNote: note, - LastBackup: lastBackup, - LastRef: lastRef, + Name: name, + Source: profile.Source, + StoreRef: profile.Store, + AuthRef: profile.AuthRef, + Enabled: profile.IsEnabled(), + Status: status, + StatusNote: note, + StoreHealth: storeHealth, + BackupState: backupState, + LastBackup: lastBackup, + LastRef: lastRef, }) } return d } -func profileStatus(cfg *engine.ProfilesConfig, p engine.BackupProfile, probe StoreProbe) (string, string) { +func profileStatus(cfg *engine.ProfilesConfig, p engine.BackupProfile, probe StoreProbe) (ProfileStatus, string) { if !p.IsEnabled() { - return "disabled", "profile disabled" + return ProfileStatusDisabled, "profile disabled" } if p.Store == "" { - return "error", "no store ref" + return ProfileStatusError, "no store ref" } if _, ok := cfg.Stores[p.Store]; !ok { - return "error", "missing store" + return ProfileStatusError, "missing store" } if p.AuthRef != "" { auth, ok := cfg.Auth[p.AuthRef] if !ok { - return "error", "missing auth ref" + return ProfileStatusError, "missing auth ref" } if provider := profileProviderFromSource(p.Source); provider != "" && auth.Provider != "" && auth.Provider != provider { - return "error", "provider mismatch" + return ProfileStatusError, "provider mismatch" } } if provider := profileProviderFromSource(p.Source); provider != "" && p.AuthRef == "" { - return "error", "missing auth" + return ProfileStatusError, "missing auth" } switch probe.Status { case "error": if probe.Error != "" { - return "warning", normalizeProbeError(probe.Error) + return ProfileStatusWarning, normalizeProbeError(probe.Error) } - return "warning", "store unavailable" + return ProfileStatusWarning, "store unavailable" case "ok": - if latest, _ := latestBackup(p.Source, probe.Snapshots); latest == "" { - return "ready", "never backed up" + if latest, _, _ := latestBackup(p.Source, probe.Snapshots); latest == "" { + return ProfileStatusReady, "never backed up" } } - return "ready", "" + return ProfileStatusReady, "" } -func latestBackup(sourceURI string, entries []engine.SnapshotEntry) (string, string) { +func latestBackup(sourceURI string, entries []engine.SnapshotEntry) (string, string, time.Time) { want := sourceKeyFromURI(sourceURI) if want.Type == "" { - return "", "" + return "", "", time.Time{} } for _, entry := range entries { if snapshotMatchesSource(entry.Snap.Source, want) { if entry.Created.IsZero() { - return "unknown time", entry.Ref + return "unknown time", entry.Ref, time.Time{} } - return entry.Created.Local().Format("2006-01-02 15:04"), entry.Ref + return entry.Created.Local().Format("2006-01-02 15:04"), entry.Ref, entry.Created + } + } + return "", "", time.Time{} +} + +func deriveStoreHealth(cfg *engine.ProfilesConfig, p engine.BackupProfile, probe StoreProbe) StoreHealth { + if !p.IsEnabled() { + return StoreHealthDisabled + } + if p.Store == "" { + return StoreHealthMissingStore + } + if _, ok := cfg.Stores[p.Store]; !ok { + return StoreHealthMissingStore + } + if provider := profileProviderFromSource(p.Source); provider != "" && p.AuthRef == "" { + return StoreHealthMissingAuth + } + if p.AuthRef != "" { + auth, ok := cfg.Auth[p.AuthRef] + if !ok { + return StoreHealthMissingAuth + } + if provider := profileProviderFromSource(p.Source); provider != "" && auth.Provider != "" && auth.Provider != provider { + return StoreHealthProviderMismatch } } - return "", "" + switch probe.Status { + case "error": + switch normalizeProbeError(probe.Error) { + case "repository not initialized": + return StoreHealthNotInitialized + case "": + return StoreHealthUnavailable + default: + return StoreHealthUnavailable + } + case "ok": + return StoreHealthReady + default: + return StoreHealthPending + } +} + +func deriveBackupState(created time.Time) BackupFreshness { + if created.IsZero() { + return BackupFreshnessUnknown + } + age := time.Since(created) + if age <= 7*24*time.Hour { + return BackupFreshnessRecent + } + return BackupFreshnessStale } type sourceKey struct { diff --git a/internal/tui/dashboard_test.go b/internal/tui/dashboard_test.go index 66a7088..4258876 100644 --- a/internal/tui/dashboard_test.go +++ b/internal/tui/dashboard_test.go @@ -67,10 +67,16 @@ 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.Profiles[0].Status != "ready" { + if got.Profiles[0].Status != ProfileStatusReady { t.Fatalf("status = %q want ready", got.Profiles[0].Status) } - if got.Profiles[1].Status != "disabled" { + if got.Profiles[0].StoreHealth != StoreHealthReady { + t.Fatalf("store health = %q want ready", got.Profiles[0].StoreHealth) + } + if got.Profiles[0].BackupState != BackupFreshnessRecent { + t.Fatalf("backup state = %q want recent", got.Profiles[0].BackupState) + } + if got.Profiles[1].Status != ProfileStatusDisabled { t.Fatalf("status = %q want disabled", got.Profiles[1].Status) } } @@ -97,12 +103,15 @@ func TestBuildDashboard_NormalizesStoreProbeErrors(t *testing.T) { if len(got.Profiles) != 1 { t.Fatalf("profiles=%d want 1", len(got.Profiles)) } - if got.Profiles[0].Status != "warning" { + if got.Profiles[0].Status != ProfileStatusWarning { t.Fatalf("status=%q want warning", got.Profiles[0].Status) } if got.Profiles[0].StatusNote != "repository not initialized" { t.Fatalf("status note=%q want repository not initialized", got.Profiles[0].StatusNote) } + if got.Profiles[0].StoreHealth != StoreHealthNotInitialized { + t.Fatalf("store health=%q want repository not initialized", got.Profiles[0].StoreHealth) + } } func TestBuildDashboardFromConfig_LoadsStoreSnapshots(t *testing.T) { @@ -145,7 +154,7 @@ func TestBuildDashboardFromConfig_StoreErrorBecomesWarning(t *testing.T) { got := BuildDashboardFromConfig(context.Background(), cfg, func(context.Context, string, engine.ProfileStore) ([]engine.SnapshotEntry, error) { return nil, errors.New("unlock failed") }) - if got.Profiles[0].Status != "warning" || got.Profiles[0].StatusNote != "unlock failed" { + if got.Profiles[0].Status != ProfileStatusWarning || got.Profiles[0].StatusNote != "unlock failed" { t.Fatalf("unexpected profile status: %+v", got.Profiles[0]) } } diff --git a/internal/tui/shell.go b/internal/tui/shell.go index 4c70ed1..9abb12c 100644 --- a/internal/tui/shell.go +++ b/internal/tui/shell.go @@ -65,7 +65,7 @@ func RenderDashboardWidth(w io.Writer, d Dashboard, width int) error { return err } - _, err := fmt.Fprintf(w, "\n%sUse ↑/↓ to select a profile. Press b to backup or init. Press q to quit.%s\n", ui.Dim, ui.Reset) + _, err := fmt.Fprintf(w, "\n%sUse ↑/↓ to select a profile. Press b to backup/init, c to check, q to quit.%s\n", ui.Dim, ui.Reset) return err } @@ -176,24 +176,31 @@ func renderSelectedProfile(d Dashboard) []string { profileDetailLine("State", plainProfileStateLabel(profile)), profileDetailLine("Source", profile.Source), profileDetailLine("Store", profile.StoreRef), + profileDetailLine("Health", storeHealthLabel(profile.StoreHealth)), } if profile.AuthRef != "" { lines = append(lines, profileDetailLine("Auth", profile.AuthRef)) } switch { case profile.LastBackup != "": - lines = append(lines, profileDetailLine("Backup", profile.LastBackup)) - case profile.Status == "ready" && profile.StatusNote == "never backed up": + backupValue := profile.LastBackup + if label := backupFreshnessLabel(profile.BackupState); label != "" { + backupValue = fmt.Sprintf("%s (%s)", backupValue, label) + } + lines = append(lines, profileDetailLine("Backup", backupValue)) + case profile.Status == ProfileStatusReady && profile.BackupState == BackupFreshnessNever: lines = append(lines, profileDetailLine("Backup", "never backed up")) } if profile.LastRef != "" { lines = append(lines, profileDetailLine("Ref", trimSnapshotRef(profile.LastRef))) } - if profile.StatusNote != "" && (profile.Status != "ready" || profile.StatusNote != "never backed up") { + if profile.StatusNote != "" && (profile.Status != ProfileStatusReady || profile.BackupState != BackupFreshnessNever) { lines = append(lines, profileDetailLine("Status", profile.StatusNote)) } lines = append(lines, "") - lines = append(lines, fmt.Sprintf("%sAction%s %s", ui.Dim, ui.Reset, selectedActionLabel(profile))) + for _, action := range selectedActionLines(profile) { + lines = append(lines, fmt.Sprintf("%sAction%s %s", ui.Dim, ui.Reset, action)) + } return lines } @@ -282,6 +289,42 @@ func equalizePaneHeights(left, right []string) ([]string, []string) { return left, right } +func storeHealthLabel(health StoreHealth) string { + switch health { + case StoreHealthReady: + return "ready" + case StoreHealthPending: + return "pending" + case StoreHealthDisabled: + return "disabled" + case StoreHealthMissingStore: + return "missing store" + case StoreHealthMissingAuth: + return "missing auth" + case StoreHealthProviderMismatch: + return "provider mismatch" + case StoreHealthUnavailable: + return "unavailable" + case StoreHealthNotInitialized: + return "repository not initialized" + default: + return "unknown" + } +} + +func backupFreshnessLabel(state BackupFreshness) string { + switch state { + case BackupFreshnessRecent: + return "recent" + case BackupFreshnessStale: + return "stale" + case BackupFreshnessNever: + return "never" + default: + return "" + } +} + func visibleLen(s string) int { n := 0 inEscape := false @@ -343,11 +386,11 @@ func truncateVisible(s string, limit int) string { func profileStateLabel(profile ProfileCard) string { switch profile.Status { - case "disabled": + case ProfileStatusDisabled: return fmt.Sprintf("%sdisabled%s", ui.Dim, ui.Reset) - case "warning": + case ProfileStatusWarning: return fmt.Sprintf("%swarning%s", ui.Cyan, ui.Reset) - case "error": + case ProfileStatusError: return "error" default: if profile.Enabled { @@ -359,8 +402,12 @@ func profileStateLabel(profile ProfileCard) string { func plainProfileStateLabel(profile ProfileCard) string { switch profile.Status { - case "disabled", "warning", "error": - return profile.Status + case ProfileStatusDisabled: + return "disabled" + case ProfileStatusWarning: + return "warning" + case ProfileStatusError: + return "error" default: if profile.Enabled { return "enabled" @@ -381,11 +428,17 @@ func selectedProfileCard(d Dashboard) (ProfileCard, bool) { return d.Profiles[0], true } -func selectedActionLabel(profile ProfileCard) string { - if plainProfileStateLabel(profile) == "warning" && strings.Contains(profile.StatusNote, "repository not initialized") { - return "Press b to initialize the repository" +func selectedActionLines(profile ProfileCard) []string { + if profile.StoreHealth == StoreHealthNotInitialized { + return []string{"Press b to initialize the repository"} + } + if profile.Status == ProfileStatusDisabled { + return []string{"No actions available for disabled profiles"} + } + if profile.Status == ProfileStatusError { + return []string{"Fix profile configuration before running actions"} } - return "Press b to run backup" + return []string{"Press b to run backup", "Press c to run repository check"} } func trimSnapshotRef(ref string) string { diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go index 096d820..eed8313 100644 --- a/internal/tui/shell_test.go +++ b/internal/tui/shell_test.go @@ -13,13 +13,15 @@ func TestRenderDashboard(t *testing.T) { SelectedProfile: "documents", Profiles: []ProfileCard{ { - Name: "documents", - Source: "local:/Users/test/Documents", - StoreRef: "remote", - Enabled: true, - Status: "ready", - LastBackup: "2026-04-03 11:05", - LastRef: "snapshot/abc123", + Name: "documents", + Source: "local:/Users/test/Documents", + StoreRef: "remote", + Enabled: true, + Status: ProfileStatusReady, + StoreHealth: StoreHealthReady, + BackupState: BackupFreshnessRecent, + LastBackup: "2026-04-03 11:05", + LastRef: "snapshot/abc123", }, }, } @@ -44,12 +46,15 @@ func TestRenderDashboard(t *testing.T) { "local:/Users/test/Documents", "Store", "remote", + "Health", + "ready", "Backup", - "2026-04-03 11:05", + "2026-04-03 11:05 (recent)", "Ref", "abc123", "No recent activity.", - "Use ↑/↓ to select a profile. Press b to backup or init. Press q to quit.", + "Press c to run repository check", + "Use ↑/↓ to select a profile. Press b to backup/init, c to check, q to quit.", } { if !strings.Contains(got, want) { t.Fatalf("missing %q in output:\n%s", want, got) diff --git a/rfcs/0012-interactive-tui-mode.md b/rfcs/0012-interactive-tui-mode.md index 43ba86b..168f9ba 100644 --- a/rfcs/0012-interactive-tui-mode.md +++ b/rfcs/0012-interactive-tui-mode.md @@ -2,7 +2,7 @@ - **Status:** Draft - **Date:** 2026-03-15 -- **Affects:** `cmd/cloudstic`, `client.go`, new `internal/{app,status,tui}` packages, docs +- **Affects:** `cmd/cloudstic`, `client.go`, `internal/tui`, docs ## Abstract @@ -35,9 +35,9 @@ core backup logic. - Add a first-party interactive TUI mode to Cloudstic. - Provide a dashboard for profiles, stores, and auth entries. - Show last backup metadata (latest snapshot time and source context). -- Allow manual backup/check actions from the UI. +- Allow manual init/backup/check actions from the UI. - Show live progress and actionable error states. -- Reuse library APIs (client/engine) rather than shelling out to CLI commands. +- Reuse library APIs and existing command internals where that materially reduces duplication. ## Non-goals @@ -58,7 +58,7 @@ cloudstic tui This launches the interactive terminal UI. -### 2. Build TUI on top of client/library APIs +### 2. Build TUI on top of existing APIs and command internals TUI behavior should call existing APIs directly: @@ -67,19 +67,23 @@ TUI behavior should call existing APIs directly: - use client list/check/backup operations for data and actions - use a TUI-specific reporter implementation for live progress -The `cmd/cloudstic` command handlers should not be used as a backend for the -TUI runtime (avoid re-parsing `os.Args`, prompt coupling, and stdout scraping). +The TUI should avoid shelling out to the `cloudstic` binary or re-parsing +`os.Args`, but it may reuse internal command helpers where those helpers already +encapsulate the correct validation and setup behavior. In the current slice, +the TUI uses a small `internal/app` orchestration service backed by CLI-side +adapters for init/backup/check execution. -### 3. Add a small application service layer +### 3. Keep the package boundary small and honest -Add internal packages to keep concerns separated: +The current implementation uses: -- `internal/app`: orchestration facade for profile-driven actions -- `internal/status`: derived view models (profile card, store health, last run) -- `internal/tui`: Bubble Tea models/views/update loop +- `internal/app`: TUI orchestration service and backend interface +- `internal/tui`: TUI view model derivation plus rendering/layout +- `cmd/cloudstic`: terminal session lifecycle, input handling, and CLI-backed + backend adapter -This layer should be designed to support a later daemon-backed mode without -requiring a TUI rewrite. +We intentionally keep this service layer narrow. Earlier sketches for +`internal/status` were too thin and were collapsed into `internal/tui`. ### 4. TUI v1 feature scope @@ -88,29 +92,26 @@ requiring a TUI rewrite. - store reference - auth reference (if any) - last backup time/status -- Store health panel with: - - credentials resolvable - - backend reachable - - repository initialized - - encrypted repo unlock validity +- Derived readiness state for each profile, including repository-not-initialized + classification and “never backed up” status - Manual actions: + - run init for selected profile store when needed - run backup for selected profile - - run check for selected profile/store + - run check for the selected profile repository - Live progress panel for current action. ### 5. Technology choice -Use Charm stack for TUI implementation: - -- `github.com/charmbracelet/bubbletea` -- `github.com/charmbracelet/bubbles` -- `github.com/charmbracelet/lipgloss` +Use a lightweight custom terminal renderer and input loop first. Rationale: -- idiomatic event-driven model for async operations -- good list/table/progress primitives -- strong ecosystem for terminal app ergonomics +- lower implementation overhead for a narrow operator dashboard +- keeps startup, testing, and portability simple +- avoids introducing a framework before the interaction model stabilizes + +This RFC does not rule out adopting Bubble Tea later, but the current +implementation is intentionally framework-free. ## UX Principles @@ -126,12 +127,13 @@ Rationale: state updates. - Health probing should run in background goroutines with bounded concurrency and visible loading/error states. +- The current implementation uses a custom alternate-screen session with raw + terminal input, resize handling, and a split-pane renderer. ## Testing Strategy -- Unit tests for `internal/status` derivation logic. -- Unit tests for `internal/app` orchestration with mocked client/store behavior. -- TUI model tests for key state transitions (load, run, error, cancel). +- Unit tests for `internal/tui` dashboard derivation and rendering logic. +- Unit tests for `cmd/cloudstic` TUI session and action helpers. - Smoke integration test for `cloudstic tui --help` and non-interactive launch guardrails. @@ -139,8 +141,8 @@ Rationale: 1. Add command scaffold and minimal screen shell. 2. Implement dashboard read path (profiles + latest snapshot info). -3. Add manual backup/check actions with live progress. -4. Add store health probes and clearer error classification. +3. Add manual init/backup/check actions with live progress. +4. Add richer store health probes and clearer error classification. 5. Polish UX, docs, and examples. ## Relationship to Daemon/Scheduling @@ -151,11 +153,14 @@ TUI and daemon are distinct concerns. - Scheduling/background execution/OS notifications are deferred to follow-up RFC 0013. -The service/status layer introduced here should be reusable by both TUI and a -future daemon. +Any future shared service layer introduced here should be reusable by both TUI +and a future daemon, but it should be justified by real reuse rather than added +preemptively. ## Open Questions - Should `cloudstic tui` support a read-only mode for diagnostics? - Should manual action history be persisted locally in v1 or deferred? - How much inline config editing should be included in v1 vs later versions? +- Should the TUI service eventually become a broader application service once + there is a second real caller beyond the TUI?