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
125 changes: 125 additions & 0 deletions cmd/cloudstic/cmd_tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"reflect"
"strings"
"testing"
"time"

cloudstic "github.com/cloudstic/cli"
"github.com/cloudstic/cli/internal/tui"
Expand Down Expand Up @@ -713,6 +714,74 @@ func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) {
}
}

func TestTUISession_HandleActionRunRefreshFailureRestoresRawMode(t *testing.T) {
readEnd, writeEnd, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
defer func() {
_ = readEnd.Close()
_ = writeEnd.Close()
}()

oldBuild := tuiBuildDashboard
oldAction := tuiRunProfileAction
oldMakeRaw := tuiMakeRaw
oldRestore := tuiRestoreTerminal
oldEnterAlt := tuiEnterAltScreen
oldLeaveAlt := tuiLeaveAltScreen
t.Cleanup(func() {
tuiBuildDashboard = oldBuild
tuiRunProfileAction = oldAction
tuiMakeRaw = oldMakeRaw
tuiRestoreTerminal = oldRestore
tuiEnterAltScreen = oldEnterAlt
tuiLeaveAltScreen = oldLeaveAlt
})

var madeRaw, restored int
state := &xterm.State{}
tuiMakeRaw = func(int) (*xterm.State, error) { madeRaw++; return state, nil }
tuiRestoreTerminal = func(int, *xterm.State) error { restored++; return nil }
tuiEnterAltScreen = func(io.Writer) error { return nil }
tuiLeaveAltScreen = func(io.Writer) error { return nil }

tuiBuildDashboard = func(context.Context, string) (tui.Dashboard, error) {
return tui.Dashboard{}, errors.New("boom")
}
tuiRunProfileAction = func(_ context.Context, _ *runner, _ string, _ tui.ProfileCard, log *tuiActionState) error {
log.Printf("backup complete")
return nil
}

s := newTUISession(&runner{out: io.Discard, stdoutFile: os.Stdout, stdin: readEnd}, "profiles.yaml", tui.Dashboard{
SelectedProfile: "docs",
Profiles: []tui.ProfileCard{
{
Name: "docs",
Source: "local:/docs",
StoreRef: "remote",
Enabled: true,
Status: tui.ProfileStatusReady,
Actions: []tui.ProfileAction{
{Kind: tui.ActionKindBackup, Key: "b", Label: "Press b to run backup", Enabled: true},
},
},
},
})
s.rawState = state

if _, err := s.handleAction(context.Background(), tuiActionRun); err == nil {
t.Fatalf("expected refresh failure")
}
if madeRaw != 1 || restored != 1 {
t.Fatalf("unexpected raw lifecycle counts: make=%d restore=%d", madeRaw, restored)
}
if s.rawState != state {
t.Fatalf("raw state not restored after refresh failure")
}
}

func TestTUISession_HandleActionCreateRefreshesDashboard(t *testing.T) {
stubTUITestHooks(t)

Expand Down Expand Up @@ -838,6 +907,62 @@ func TestTUISession_RefreshPreservesSelectionAndActivity(t *testing.T) {
}
}

func TestRunTUIActionIntoDashboard_RedrawsUsingCurrentWidthDuringLongAction(t *testing.T) {
stubTUITestHooks(t)

oldWidth := tuiGetTerminalSize
oldAction := tuiRunProfileAction
t.Cleanup(func() {
tuiGetTerminalSize = oldWidth
tuiRunProfileAction = oldAction
})

var widthCalls int
tuiGetTerminalSize = func(int) (int, int, error) {
widthCalls++
if widthCalls == 1 {
return 120, 40, nil
}
return 72, 40, nil
}
tuiRunProfileAction = func(_ context.Context, _ *runner, _ string, _ tui.ProfileCard, log *tuiActionState) error {
phase := log.Reporter().StartPhase("Uploading", 4, false)
time.Sleep(120 * time.Millisecond)
phase.Increment(2)
time.Sleep(120 * time.Millisecond)
phase.Increment(2)
phase.Done()
return nil
}

var out strings.Builder
dashboard := tui.Dashboard{
SelectedProfile: "docs",
Profiles: []tui.ProfileCard{
{
Name: "docs",
Source: "local:/docs",
StoreRef: "remote",
Enabled: true,
Status: tui.ProfileStatusReady,
Actions: []tui.ProfileAction{
{Kind: tui.ActionKindBackup, Key: "b", Label: "Press b to run backup", Enabled: true},
},
},
},
}
result := runTUIActionIntoDashboard(context.Background(), &runner{out: &out, stdoutFile: os.Stdout}, "profiles.yaml", dashboard)
if widthCalls < 2 {
t.Fatalf("expected multiple width polls during long action, got %d", widthCalls)
}
if result.Activity.Status != tui.ActivityStatusSuccess {
t.Fatalf("unexpected activity status: %+v", result.Activity)
}
if !strings.Contains(out.String(), "Progress") {
t.Fatalf("expected live renders with progress, got:\n%s", out.String())
}
}

func TestCaptureTUIRunnerOutput_RestoresRunnerState(t *testing.T) {
var out strings.Builder
var errOut strings.Builder
Expand Down
46 changes: 28 additions & 18 deletions cmd/cloudstic/tui_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,26 +279,24 @@ func (s *tuiSession) handleAction(ctx context.Context, action tuiAction) (int, e
case tuiActionDown:
s.dashboard = moveTUISelection(s.dashboard, 1)
case tuiActionRun:
if err := s.suspendRaw(); err != nil {
return -1, fmt.Errorf("failed to configure terminal: %v", err)
}
s.dashboard = runTUIActionIntoDashboard(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)
if err := s.runSuspended(ctx, func(ctx context.Context) error {
s.dashboard = runTUIActionIntoDashboard(ctx, s.r, s.profilesFile, s.dashboard)
if err := s.refresh(ctx); err != nil {
return fmt.Errorf("failed to refresh TUI dashboard: %v", err)
}
return nil
}); err != nil {
return -1, 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)
if err := s.runSuspended(ctx, func(ctx context.Context) error {
s.dashboard = runTUICheckIntoDashboard(ctx, s.r, s.profilesFile, s.dashboard)
if err := s.refresh(ctx); err != nil {
return fmt.Errorf("failed to refresh TUI dashboard: %v", err)
}
return nil
}); err != nil {
return -1, err
}
case tuiActionCreate:
if err := s.runProfileModal(ctx, "", false); err != nil {
Expand Down Expand Up @@ -328,6 +326,18 @@ func (s *tuiSession) handleAction(ctx context.Context, action tuiAction) (int, e
return -1, s.render()
}

func (s *tuiSession) runSuspended(ctx context.Context, fn func(context.Context) error) (err error) {
if err := s.suspendRaw(); err != nil {
return fmt.Errorf("failed to configure terminal: %v", err)
}
defer func() {
if resumeErr := s.resumeRaw(); err == nil && resumeErr != nil {
err = fmt.Errorf("failed to configure terminal: %v", resumeErr)
}
}()
return fn(ctx)
}

func (s *tuiSession) refresh(ctx context.Context) error {
selected := s.dashboard.SelectedProfile
activity := s.dashboard.Activity
Expand Down
6 changes: 5 additions & 1 deletion internal/tui/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ func dashboardLinesWidth(d Dashboard, width int) []string {
)...)

lines = append(lines, boxLinesExact("Activity", renderActivityPanel(d.Activity), panelWidth(width))...)
lines = append(lines, "", 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 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)
}
lines = append(lines, "", footer)
return lines
}

Expand Down
86 changes: 86 additions & 0 deletions internal/tui/shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,89 @@ func TestRenderDashboardWithModal(t *testing.T) {
t.Fatalf("did not expect example when source field is not active:\n%s", got)
}
}

func TestDashboardLinesWidth_TruncatesForNarrowTerminals(t *testing.T) {
d := Dashboard{
ProfileCount: 1,
StoreCount: 1,
SelectedProfile: "google-test",
Activity: ActivityPanel{
Status: ActivityStatusSuccess,
Action: "Run backup (profile google-test)",
Summary: "completed successfully",
Lines: []string{"Snapshot c9a98d85cd65e691c427554664c612c4014ff25572644e4ce4a158ecd593a773 saved"},
},
Profiles: []ProfileCard{
{
Name: "google-test",
Source: "gdrive-changes:/Very Long Shared Drive Name/Extremely Long Folder Name",
StoreRef: "default-store",
AuthRef: "google-google-test",
Enabled: true,
Status: ProfileStatusReady,
StoreHealth: StoreHealthReady,
BackupState: BackupFreshnessRecent,
LastBackup: "2026-04-03 14:53",
LastRef: "snapshot/c9a98d85cd65e691c427554664c612c4014ff25572644e4ce4a158ecd593a773",
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},
},
},
},
}

lines := dashboardLinesWidth(d, 72)
for _, line := range lines {
if got := visibleLen(line); got > 72 {
t.Fatalf("line width=%d exceeds terminal width: %q", got, line)
}
}
got := strings.Join(lines, "\n")
if !strings.Contains(got, "…") {
t.Fatalf("expected truncated content in narrow layout:\n%s", got)
}
}

func TestLayoutDashboardWidth_TracksProfileRowsAndActionRect(t *testing.T) {
d := Dashboard{
ProfileCount: 2,
StoreCount: 1,
SelectedProfile: "photos",
Profiles: []ProfileCard{
{Name: "docs", Enabled: true, Status: ProfileStatusReady},
{
Name: "photos",
Enabled: true,
Status: ProfileStatusReady,
Actions: []ProfileAction{{Kind: ActionKindBackup, Key: "b", Label: "Press b to run backup", Enabled: true}},
StoreRef: "remote",
Source: "local:/photos",
},
},
}

layout := LayoutDashboardWidth(d, 100)
if len(layout.ProfileRows) != 2 {
t.Fatalf("profile rows=%d want 2", len(layout.ProfileRows))
}
foundDocs := false
foundPhotos := false
for _, name := range layout.ProfileRows {
if name == "docs" {
foundDocs = true
}
if name == "photos" {
foundPhotos = true
}
}
if !foundDocs || !foundPhotos {
t.Fatalf("unexpected profile row mapping: %+v", layout.ProfileRows)
}
if layout.ActionRect.W <= 0 || layout.ActionRect.H != 1 {
t.Fatalf("unexpected action rect: %+v", layout.ActionRect)
}
if layout.ActionRect.X <= 0 || layout.ActionRect.Y <= 0 {
t.Fatalf("unexpected action rect origin: %+v", layout.ActionRect)
}
}
Loading