Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions cmd/cloudstic/cmd_tui_activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down
14 changes: 12 additions & 2 deletions cmd/cloudstic/cmd_tui_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"io"
"os"
"strings"

"github.com/cloudstic/cli/internal/tui"
)
Expand All @@ -17,6 +16,7 @@ const (
tuiActionUp
tuiActionDown
tuiActionRun
tuiActionCheck
tuiActionQuit
)

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
114 changes: 86 additions & 28 deletions cmd/cloudstic/cmd_tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}},
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -230,17 +230,19 @@ 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{
ProfileCount: 1,
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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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},
},
})

Expand All @@ -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
}
Expand All @@ -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
Expand Down
Loading
Loading