From 427089fd8d45ee872d1ef3452b3400a44316a5c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Fri, 3 Apr 2026 16:53:25 +0200 Subject: [PATCH] Add interactive TUI dashboard slice --- README.md | 3 + cmd/cloudstic/cmd_init.go | 3 + cmd/cloudstic/cmd_tui.go | 56 +++ cmd/cloudstic/cmd_tui_activity.go | 250 +++++++++++ cmd/cloudstic/cmd_tui_input.go | 160 +++++++ cmd/cloudstic/cmd_tui_resize_unix.go | 17 + cmd/cloudstic/cmd_tui_resize_windows.go | 9 + cmd/cloudstic/cmd_tui_test.go | 542 ++++++++++++++++++++++++ cmd/cloudstic/completion.go | 4 +- cmd/cloudstic/completion_test.go | 4 +- cmd/cloudstic/interactive.go | 13 +- cmd/cloudstic/main.go | 2 + cmd/cloudstic/runner.go | 2 + cmd/cloudstic/store.go | 22 +- cmd/cloudstic/tui_runtime.go | 292 +++++++++++++ cmd/cloudstic/usage.go | 1 + docs/user-guide.md | 29 ++ e2e/feature_tui_test.go | 30 ++ internal/tui/dashboard.go | 265 ++++++++++++ internal/tui/dashboard_test.go | 160 +++++++ internal/tui/shell.go | 393 +++++++++++++++++ internal/tui/shell_test.go | 58 +++ 22 files changed, 2303 insertions(+), 12 deletions(-) create mode 100644 cmd/cloudstic/cmd_tui.go create mode 100644 cmd/cloudstic/cmd_tui_activity.go create mode 100644 cmd/cloudstic/cmd_tui_input.go create mode 100644 cmd/cloudstic/cmd_tui_resize_unix.go create mode 100644 cmd/cloudstic/cmd_tui_resize_windows.go create mode 100644 cmd/cloudstic/cmd_tui_test.go create mode 100644 cmd/cloudstic/tui_runtime.go create mode 100644 e2e/feature_tui_test.go create mode 100644 internal/tui/dashboard.go create mode 100644 internal/tui/dashboard_test.go create mode 100644 internal/tui/shell.go create mode 100644 internal/tui/shell_test.go diff --git a/README.md b/README.md index a394c99..d402fdb 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,9 @@ cloudstic source discover -portable-only # Preview a workstation onboarding plan cloudstic setup workstation -dry-run + +# Launch the interactive dashboard for configured profiles +cloudstic tui ``` ## Profiles diff --git a/cmd/cloudstic/cmd_init.go b/cmd/cloudstic/cmd_init.go index aaa54c8..a3f25e2 100644 --- a/cmd/cloudstic/cmd_init.go +++ b/cmd/cloudstic/cmd_init.go @@ -35,7 +35,10 @@ func parseInitArgs() *initArgs { func (r *runner) runInit(ctx context.Context) int { a := parseInitArgs() + return r.runInitWithArgs(ctx, a) +} +func (r *runner) runInitWithArgs(ctx context.Context, a *initArgs) int { raw, err := a.g.openStore() if err != nil { return r.fail("Failed to init store: %v", err) diff --git a/cmd/cloudstic/cmd_tui.go b/cmd/cloudstic/cmd_tui.go new file mode 100644 index 0000000..3d59f1a --- /dev/null +++ b/cmd/cloudstic/cmd_tui.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" +) + +type tuiArgs struct { + profilesFile string +} + +func parseTUIArgs() (*tuiArgs, error) { + fs := flag.NewFlagSet("tui", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + a := &tuiArgs{} + fs.StringVar(&a.profilesFile, "profiles-file", defaultProfilesPathNoCreate(), "Path to profiles YAML file") + if err := fs.Parse(reorderArgs(fs, os.Args[2:])); err != nil { + return nil, err + } + return a, nil +} + +func printTUIUsage(w io.Writer) { + _, _ = fmt.Fprintln(w, "Usage: cloudstic tui [options]") + _, _ = fmt.Fprintln(w, "") + _, _ = fmt.Fprintln(w, "Launch the interactive terminal dashboard.") + _, _ = fmt.Fprintln(w, "") + _, _ = fmt.Fprintln(w, "Options:") + _, _ = fmt.Fprintf(w, " -profiles-file Path to profiles YAML file (default %s)\n", defaultProfilesPathNoCreate()) +} + +func (r *runner) runTUI(ctx context.Context) int { + for _, arg := range os.Args[2:] { + if arg == "-h" || arg == "--help" || arg == "help" { + printTUIUsage(r.out) + return 0 + } + } + + args, err := parseTUIArgs() + if err != nil { + return 1 + } + if !r.canPrompt() { + return r.fail("cloudstic tui requires an interactive terminal") + } + + dashboard, err := tuiBuildDashboard(ctx, args.profilesFile) + if err != nil { + return r.fail("Failed to build TUI dashboard: %v", err) + } + return newTUISession(r, args.profilesFile, dashboard).run(ctx) +} diff --git a/cmd/cloudstic/cmd_tui_activity.go b/cmd/cloudstic/cmd_tui_activity.go new file mode 100644 index 0000000..edf0aad --- /dev/null +++ b/cmd/cloudstic/cmd_tui_activity.go @@ -0,0 +1,250 @@ +package main + +import ( + "bytes" + "context" + "flag" + "fmt" + "io" + "strings" + "sync" + "time" + + cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/tui" +) + +func renderTUIScreenWidth(w io.Writer, dashboard tui.Dashboard, width int) error { + if _, err := fmt.Fprint(w, "\x1b[2J\x1b[H"); err != nil { + return err + } + return tui.RenderDashboardWidth(newCRLFWriter(w), dashboard, width) +} + +func runTUIActionIntoDashboard(ctx context.Context, r *runner, profilesFile string, dashboard tui.Dashboard) tui.Dashboard { + log := newTUIActionState(10) + screen := r.out + if profile, ok := selectedTUIProfile(dashboard); ok { + if profileNeedsInit(profile) { + log.Printf("Initializing store for profile %s", profile.Name) + } else { + log.Printf("Running backup 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 := runSelectedTUIAction(ctx, r, profilesFile, dashboard, log); err != nil { + log.Printf("Action failed: %v", err) + } else { + log.Printf("Action 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...) + if len(merged) > 10 { + merged = merged[:10] + } + return merged +} + +type crlfWriter struct { + w io.Writer +} + +func newCRLFWriter(w io.Writer) io.Writer { + return crlfWriter{w: w} +} + +func (w crlfWriter) Write(p []byte) (int, error) { + s := strings.ReplaceAll(string(p), "\n", "\r\n") + if _, err := io.WriteString(w.w, s); err != nil { + return 0, err + } + return len(p), nil +} + +func captureTUIRunnerOutput(r *runner, log *tuiActionState) func() { + oldOut := r.out + oldErrOut := r.errOut + oldNoPrompt := r.noPrompt + r.out = log.Writer() + r.errOut = log.Writer() + r.noPrompt = true + return func() { + r.out = oldOut + r.errOut = oldErrOut + r.noPrompt = oldNoPrompt + } +} + +type tuiActionState struct { + mu sync.Mutex + lines []string + limit int + buf bytes.Buffer + phase *tuiPhaseState +} + +type tuiPhaseState struct { + name string + current int64 + total int64 + isBytes bool + state string +} + +func newTUIActionState(limit int) *tuiActionState { + return &tuiActionState{limit: limit} +} + +func (l *tuiActionState) Writer() io.Writer { + return l +} + +func (l *tuiActionState) Reporter() cloudstic.Reporter { + return tuiReporter{state: l} +} + +func (l *tuiActionState) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + l.buf.Write(p) + for { + line, err := l.buf.ReadString('\n') + if err != nil { + l.buf.WriteString(line) + break + } + l.append(strings.TrimSpace(line)) + } + return len(p), nil +} + +func (l *tuiActionState) Printf(format string, args ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.append(fmt.Sprintf(format, args...)) +} + +func (l *tuiActionState) Lines() []string { + l.mu.Lock() + defer l.mu.Unlock() + if tail := strings.TrimSpace(l.buf.String()); tail != "" { + l.append(tail) + l.buf.Reset() + } + lines := append([]string{}, l.lines...) + if summary := l.phaseSummary(); summary != "" { + lines = append([]string{summary}, lines...) + } + return lines +} + +func (l *tuiActionState) append(line string) { + line = strings.TrimSpace(line) + if line == "" { + return + } + l.lines = append([]string{line}, l.lines...) + if len(l.lines) > l.limit { + l.lines = l.lines[:l.limit] + } +} + +func (l *tuiActionState) phaseSummary() 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 + } +} + +type tuiReporter struct { + state *tuiActionState +} + +func (r tuiReporter) StartPhase(name string, total int64, isBytes bool) cloudstic.Phase { + r.state.mu.Lock() + defer r.state.mu.Unlock() + r.state.phase = &tuiPhaseState{name: name, total: total, isBytes: isBytes, state: "active"} + return tuiReporterPhase(r) +} + +type tuiReporterPhase struct { + state *tuiActionState +} + +func (p tuiReporterPhase) Increment(n int64) { + p.state.mu.Lock() + defer p.state.mu.Unlock() + if p.state.phase != nil { + p.state.phase.current += n + } +} + +func (p tuiReporterPhase) Log(msg string) { + p.state.mu.Lock() + defer p.state.mu.Unlock() + p.state.append(msg) +} + +func (p tuiReporterPhase) Done() { + p.state.mu.Lock() + defer p.state.mu.Unlock() + if p.state.phase != nil { + p.state.phase.state = "done" + } +} + +func (p tuiReporterPhase) Error() { + p.state.mu.Lock() + defer p.state.mu.Unlock() + if p.state.phase != nil { + p.state.phase.state = "error" + } +} + +func tuiStoreFlags(profilesFile string, storeCfg cloudstic.ProfileStore) *globalFlags { + fs := flag.NewFlagSet("tui-store", flag.ContinueOnError) + g := addGlobalFlags(fs) + *g.profilesFile = profilesFile + flagsSet := map[string]bool{} + _ = applyProfileStoreToGlobalFlags(g, storeCfg, flagsSet) + quiet := true + debug := false + verbose := false + g.quiet = &quiet + g.debug = &debug + g.verbose = &verbose + return g +} diff --git a/cmd/cloudstic/cmd_tui_input.go b/cmd/cloudstic/cmd_tui_input.go new file mode 100644 index 0000000..8eb5923 --- /dev/null +++ b/cmd/cloudstic/cmd_tui_input.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/cloudstic/cli/internal/tui" +) + +type tuiAction int + +const ( + tuiActionNone tuiAction = iota + tuiActionUp + tuiActionDown + tuiActionRun + tuiActionQuit +) + +func ensureSelectedProfile(d tui.Dashboard) tui.Dashboard { + if d.SelectedProfile != "" || len(d.Profiles) == 0 { + return d + } + d.SelectedProfile = d.Profiles[0].Name + return d +} + +func tuiWidth(r *runner) int { + stdout := r.stdoutFile + if stdout == nil { + stdout = os.Stdout + } + if tuiGetTerminalSize == nil { + return 100 + } + width, _, err := tuiGetTerminalSize(int(stdout.Fd())) + if err != nil || width <= 0 { + return 100 + } + return width +} + +func moveTUISelection(d tui.Dashboard, delta int) tui.Dashboard { + if len(d.Profiles) == 0 || delta == 0 { + return d + } + current := 0 + for i, profile := range d.Profiles { + if profile.Name == d.SelectedProfile { + current = i + break + } + } + next := current + delta + if next < 0 { + next = len(d.Profiles) - 1 + } + if next >= len(d.Profiles) { + next = 0 + } + d.SelectedProfile = d.Profiles[next].Name + return d +} + +func readTUIAction(r io.ByteReader) (tuiAction, error) { + b, err := r.ReadByte() + if err != nil { + return tuiActionNone, err + } + switch b { + case 'q', 'Q': + return tuiActionQuit, nil + case 'j', 'J': + return tuiActionDown, nil + case 'k', 'K': + return tuiActionUp, nil + case 'b', 'B': + return tuiActionRun, nil + case 0x1b: + next, err := r.ReadByte() + if err != nil { + return tuiActionNone, nil + } + if next == 'O' { + dir, err := r.ReadByte() + if err != nil { + return tuiActionNone, nil + } + switch dir { + case 'A': + return tuiActionUp, nil + case 'B': + return tuiActionDown, nil + default: + return tuiActionNone, nil + } + } + if next != '[' { + return tuiActionNone, nil + } + csi, err := readTUICSISequence(r) + if err != nil || len(csi) == 0 { + return tuiActionNone, nil + } + switch csi[len(csi)-1] { + case 'A': + return tuiActionUp, nil + case 'B': + return tuiActionDown, nil + default: + return tuiActionNone, nil + } + default: + return tuiActionNone, nil + } +} + +func readTUICSISequence(r io.ByteReader) ([]byte, error) { + var seq []byte + for { + b, err := r.ReadByte() + if err != nil { + return nil, err + } + seq = append(seq, b) + if b >= 0x40 && b <= 0x7e { + return seq, nil + } + if len(seq) > 32 { + return seq, fmt.Errorf("csi sequence too long") + } + } +} + +func runSelectedTUIAction(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 tuiRunProfileAction(ctx, r, profilesFile, profile, log) +} + +func selectedTUIProfile(d tui.Dashboard) (tui.ProfileCard, bool) { + for _, profile := range d.Profiles { + if profile.Name == d.SelectedProfile { + return profile, true + } + } + if len(d.Profiles) == 0 { + return tui.ProfileCard{}, false + } + return d.Profiles[0], true +} + +func profileNeedsInit(profile tui.ProfileCard) bool { + return strings.Contains(profile.StatusNote, "repository not initialized") +} diff --git a/cmd/cloudstic/cmd_tui_resize_unix.go b/cmd/cloudstic/cmd_tui_resize_unix.go new file mode 100644 index 0000000..797e2aa --- /dev/null +++ b/cmd/cloudstic/cmd_tui_resize_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package main + +import ( + "os" + "os/signal" + "syscall" +) + +func tuiNotifyResize(ch chan<- os.Signal) { + signal.Notify(ch, syscall.SIGWINCH) +} + +func tuiStopResize(ch chan<- os.Signal) { + signal.Stop(ch) +} diff --git a/cmd/cloudstic/cmd_tui_resize_windows.go b/cmd/cloudstic/cmd_tui_resize_windows.go new file mode 100644 index 0000000..cdde8fd --- /dev/null +++ b/cmd/cloudstic/cmd_tui_resize_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package main + +import "os" + +func tuiNotifyResize(ch chan<- os.Signal) {} + +func tuiStopResize(ch chan<- os.Signal) {} diff --git a/cmd/cloudstic/cmd_tui_test.go b/cmd/cloudstic/cmd_tui_test.go new file mode 100644 index 0000000..784d333 --- /dev/null +++ b/cmd/cloudstic/cmd_tui_test.go @@ -0,0 +1,542 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "errors" + "io" + "os" + "strings" + "testing" + + cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/tui" + xterm "golang.org/x/term" +) + +func stubTUITestHooks(t *testing.T) { + t.Helper() + + oldIsTerminal := isTerminalFunc + oldMakeRaw := tuiMakeRaw + oldRestore := tuiRestoreTerminal + oldEnterAlt := tuiEnterAltScreen + oldLeaveAlt := tuiLeaveAltScreen + + isTerminalFunc = func(uintptr) bool { return true } + tuiMakeRaw = func(int) (*xterm.State, error) { return nil, nil } + tuiRestoreTerminal = func(int, *xterm.State) error { return nil } + tuiEnterAltScreen = func(io.Writer) error { return nil } + tuiLeaveAltScreen = func(io.Writer) error { return nil } + + t.Cleanup(func() { + isTerminalFunc = oldIsTerminal + tuiMakeRaw = oldMakeRaw + tuiRestoreTerminal = oldRestore + tuiEnterAltScreen = oldEnterAlt + tuiLeaveAltScreen = oldLeaveAlt + }) +} + +func TestRunTUI_Help(t *testing.T) { + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = []string{"cloudstic", "tui", "--help"} + + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut} + if code := r.runTUI(context.Background()); code != 0 { + t.Fatalf("code=%d err=%s", code, errOut.String()) + } + if !strings.Contains(out.String(), "Usage: cloudstic tui [options]") { + t.Fatalf("unexpected help output:\n%s", out.String()) + } +} + +func TestRunTUI_RequiresInteractiveTerminal(t *testing.T) { + oldIsTerminal := isTerminalFunc + t.Cleanup(func() { isTerminalFunc = oldIsTerminal }) + isTerminalFunc = func(uintptr) bool { return false } + + 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() }() + _ = writeEnd.Close() + + var out strings.Builder + var errOut strings.Builder + r := &runner{ + out: &out, + errOut: &errOut, + stdin: readEnd, + lineIn: bufio.NewReader(readEnd), + } + if code := r.runTUI(context.Background()); code == 0 { + t.Fatalf("expected failure for non-interactive terminal") + } + if !strings.Contains(errOut.String(), "requires an interactive terminal") { + t.Fatalf("unexpected stderr:\n%s", errOut.String()) + } +} + +func TestRunTUI_RendersDashboardAndQuitsOnQ(t *testing.T) { + stubTUITestHooks(t) + + dir := t.TempDir() + profilesPath := dir + "/profiles.yaml" + if err := cloudstic.SaveProfilesFile(profilesPath, &cloudstic.ProfilesConfig{ + Version: 1, + Stores: map[string]cloudstic.ProfileStore{ + "remote": {URI: "s3:bucket/prod"}, + }, + Profiles: map[string]cloudstic.BackupProfile{ + "documents": {Source: "local:/tmp/Documents", Store: "remote"}, + }, + }); err != nil { + t.Fatalf("SaveProfilesFile: %v", err) + } + + oldArgs := os.Args + oldNoPrompt := os.Getenv("CLOUDSTIC_PROFILES_FILE") + t.Cleanup(func() { + os.Args = oldArgs + _ = os.Setenv("CLOUDSTIC_PROFILES_FILE", oldNoPrompt) + }) + _ = os.Setenv("CLOUDSTIC_PROFILES_FILE", profilesPath) + os.Args = []string{"cloudstic", "tui", "-profiles-file", profilesPath} + + readEnd, writeEnd, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + defer func() { _ = readEnd.Close() }() + if _, err := writeEnd.WriteString("q"); err != nil { + t.Fatalf("WriteString: %v", err) + } + _ = writeEnd.Close() + + var out strings.Builder + var errOut strings.Builder + oldBuild := tuiBuildDashboard + t.Cleanup(func() { tuiBuildDashboard = oldBuild }) + tuiBuildDashboard = func(context.Context, string) (tui.Dashboard, error) { + return tui.Dashboard{ + ProfileCount: 1, + StoreCount: 1, + AuthCount: 0, + SelectedProfile: "documents", + Profiles: []tui.ProfileCard{{ + Name: "documents", + Source: "local:/tmp/Documents", + StoreRef: "remote", + Enabled: true, + Status: "ready", + LastBackup: "2026-04-03 11:05", + LastRef: "snapshot/abc123", + }}, + }, 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()) + } + got := out.String() + if !strings.Contains(got, "Cloudstic TUI") || !strings.Contains(got, "documents") || !strings.Contains(got, "enabled") || !strings.Contains(got, "›") { + t.Fatalf("unexpected output:\n%s", got) + } +} + +func TestRunTUI_ArrowNavigationChangesSelection(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("\x1b[Bq"); err != nil { + t.Fatalf("WriteString: %v", err) + } + _ = writeEnd.Close() + + var out strings.Builder + var errOut strings.Builder + oldBuild := tuiBuildDashboard + t.Cleanup(func() { tuiBuildDashboard = oldBuild }) + tuiBuildDashboard = func(context.Context, string) (tui.Dashboard, error) { + return tui.Dashboard{ + 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"}, + }, + }, 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()) + } + got := out.String() + if !strings.Contains(got, "\x1b[36m› \x1b[0m\x1b[1mphotos\x1b[0m") { + t.Fatalf("expected selection to move to photos, got:\n%s", got) + } +} + +func TestRunTUI_BackupActionRunsSelectedProfileAction(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("bq"); err != nil { + t.Fatalf("WriteString: %v", err) + } + _ = writeEnd.Close() + + var out strings.Builder + var errOut strings.Builder + var ranProfile string + oldBuild := tuiBuildDashboard + oldAction := tuiRunProfileAction + t.Cleanup(func() { + tuiBuildDashboard = oldBuild + tuiRunProfileAction = oldAction + }) + 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"}, + }, + }, nil + } + tuiRunProfileAction = func(_ context.Context, _ *runner, _ string, profile tui.ProfileCard, _ *tuiActionState) error { + ranProfile = 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 ranProfile != "documents" { + t.Fatalf("selected action ran for %q, want documents", ranProfile) + } + if !strings.Contains(out.String(), "Running backup for profile documents") { + t.Fatalf("expected activity log in dashboard, got:\n%s", out.String()) + } + if !strings.Contains(out.String(), "Action completed successfully") { + t.Fatalf("expected success activity log in dashboard, got:\n%s", out.String()) + } + if errOut.Len() != 0 { + t.Fatalf("expected no stderr spillover, got:\n%s", errOut.String()) + } +} + +func TestReadTUIAction_ParsesCSIArrowKeys(t *testing.T) { + ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[A"))) + if err != nil { + t.Fatalf("readTUIAction up: %v", err) + } + if ev != tuiActionUp { + t.Fatalf("up action=%v want %v", ev, tuiActionUp) + } + + ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[B"))) + if err != nil { + t.Fatalf("readTUIAction down: %v", err) + } + if ev != tuiActionDown { + t.Fatalf("down action=%v want %v", ev, tuiActionDown) + } +} + +func TestReadTUIAction_ParsesParameterizedCSIArrowKeys(t *testing.T) { + ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[1;2A"))) + if err != nil { + t.Fatalf("readTUIAction param up: %v", err) + } + if ev != tuiActionUp { + t.Fatalf("param up action=%v want %v", ev, tuiActionUp) + } + + ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[1;2B"))) + if err != nil { + t.Fatalf("readTUIAction param down: %v", err) + } + if ev != tuiActionDown { + t.Fatalf("param down action=%v want %v", ev, tuiActionDown) + } +} + +func TestReadTUIAction_ParsesSS3ArrowKeys(t *testing.T) { + ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1bOA"))) + if err != nil { + t.Fatalf("readTUIAction ss3 up: %v", err) + } + if ev != tuiActionUp { + t.Fatalf("ss3 up action=%v want %v", ev, tuiActionUp) + } + + ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1bOB"))) + if err != nil { + t.Fatalf("readTUIAction ss3 down: %v", err) + } + if ev != tuiActionDown { + t.Fatalf("ss3 down action=%v want %v", ev, tuiActionDown) + } +} + +func TestTUISession_EnterLeaveManagesTerminalState(t *testing.T) { + readEnd, writeEnd, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + defer func() { + _ = readEnd.Close() + _ = writeEnd.Close() + }() + + oldMakeRaw := tuiMakeRaw + oldRestore := tuiRestoreTerminal + oldEnterAlt := tuiEnterAltScreen + oldLeaveAlt := tuiLeaveAltScreen + t.Cleanup(func() { + tuiMakeRaw = oldMakeRaw + tuiRestoreTerminal = oldRestore + tuiEnterAltScreen = oldEnterAlt + tuiLeaveAltScreen = oldLeaveAlt + }) + + var enteredAlt, leftAlt, madeRaw, restored int + state := &xterm.State{} + tuiEnterAltScreen = func(io.Writer) error { enteredAlt++; return nil } + tuiLeaveAltScreen = func(io.Writer) error { leftAlt++; return nil } + tuiMakeRaw = func(int) (*xterm.State, error) { madeRaw++; return state, nil } + tuiRestoreTerminal = func(int, *xterm.State) error { restored++; return nil } + + s := newTUISession(&runner{out: io.Discard, stdin: readEnd}, "", tui.Dashboard{}) + if err := s.enter(); err != nil { + t.Fatalf("enter: %v", err) + } + if s.rawState != state { + t.Fatalf("rawState not set") + } + s.leave() + if enteredAlt != 1 || leftAlt != 1 || madeRaw != 1 || restored != 1 { + t.Fatalf("unexpected terminal lifecycle counts: alt=%d/%d raw=%d restore=%d", enteredAlt, leftAlt, madeRaw, restored) + } + if s.rawState != nil { + t.Fatalf("rawState not cleared") + } +} + +func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) { + stubTUITestHooks(t) + + oldBuild := tuiBuildDashboard + oldAction := tuiRunProfileAction + t.Cleanup(func() { + tuiBuildDashboard = oldBuild + tuiRunProfileAction = oldAction + }) + + tuiBuildDashboard = func(context.Context, string) (tui.Dashboard, error) { + return tui.Dashboard{ + ProfileCount: 1, + StoreCount: 1, + SelectedProfile: "docs", + Profiles: []tui.ProfileCard{ + {Name: "docs", Source: "local:/docs", StoreRef: "remote", Enabled: true, Status: "ready", LastBackup: "2026-04-03 12:00"}, + }, + }, nil + } + tuiRunProfileAction = func(_ context.Context, _ *runner, _ string, _ tui.ProfileCard, log *tuiActionState) error { + log.Printf("backup complete") + return nil + } + + var out strings.Builder + 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"}, + }, + }) + + if _, err := s.handleAction(context.Background(), tuiActionRun); err != nil { + t.Fatalf("handleAction(run): %v", err) + } + if s.dashboard.SelectedProfile != "docs" { + t.Fatalf("selected profile lost after refresh: %+v", s.dashboard) + } + if len(s.dashboard.ActivityLines) == 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) + } +} + +func TestTUISession_RefreshPreservesSelectionAndActivity(t *testing.T) { + oldBuild := tuiBuildDashboard + t.Cleanup(func() { tuiBuildDashboard = oldBuild }) + 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"}, + }, + }, nil + } + + s := newTUISession(&runner{}, "profiles.yaml", tui.Dashboard{ + SelectedProfile: "docs", + ActivityLines: []string{"running"}, + Profiles: []tui.ProfileCard{{Name: "docs"}}, + }) + if err := s.refresh(context.Background()); err != nil { + t.Fatalf("refresh: %v", err) + } + 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) + } +} + +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 + r := &runner{out: &out, errOut: &errOut} + log := newTUIActionState(5) + + restore := captureTUIRunnerOutput(r, log) + if _, err := io.WriteString(r.out, "hello\n"); err != nil { + t.Fatalf("write captured output: %v", err) + } + restore() + + if got := strings.Join(log.Lines(), "\n"); !strings.Contains(got, "hello") { + t.Fatalf("captured log missing output: %q", got) + } + if r.out != &out || r.errOut != &errOut { + t.Fatalf("runner outputs not restored") + } +} + +func TestReadInput_ClosesChannelOnEOF(t *testing.T) { + readEnd, writeEnd, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + defer func() { _ = readEnd.Close() }() + _ = writeEnd.Close() + + s := newTUISession(&runner{stdin: readEnd, lineIn: bufio.NewReader(readEnd)}, "", tui.Dashboard{}) + eventCh := make(chan tuiAction, 2) + errCh := make(chan error, 1) + s.readInput(eventCh, errCh) + + if _, ok := <-eventCh; ok { + t.Fatalf("expected event channel to be closed") + } + select { + case err := <-errCh: + t.Fatalf("unexpected read error: %v", err) + default: + } +} + +func TestTUIBuildDashboardErrorPropagates(t *testing.T) { + oldBuild := tuiBuildDashboard + t.Cleanup(func() { tuiBuildDashboard = oldBuild }) + tuiBuildDashboard = func(context.Context, string) (tui.Dashboard, error) { + return tui.Dashboard{}, errors.New("boom") + } + + oldArgs := os.Args + oldIsTerminal := isTerminalFunc + t.Cleanup(func() { + os.Args = oldArgs + isTerminalFunc = oldIsTerminal + }) + os.Args = []string{"cloudstic", "tui"} + isTerminalFunc = func(uintptr) bool { return true } + + readEnd, writeEnd, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + defer func() { _ = readEnd.Close() }() + _ = writeEnd.Close() + + var errOut strings.Builder + r := &runner{out: io.Discard, errOut: &errOut, stdin: readEnd, stdoutFile: os.Stdout, lineIn: bufio.NewReader(readEnd)} + if code := r.runTUI(context.Background()); code == 0 { + t.Fatalf("expected failure") + } + if !strings.Contains(errOut.String(), "Failed to build TUI dashboard") { + t.Fatalf("unexpected stderr: %s", errOut.String()) + } +} diff --git a/cmd/cloudstic/completion.go b/cmd/cloudstic/completion.go index 066e857..bbbee23 100644 --- a/cmd/cloudstic/completion.go +++ b/cmd/cloudstic/completion.go @@ -48,7 +48,7 @@ _cloudstic() { local cur prev words cword _init_completion || return - local commands="init backup auth profile store source setup restore list ls prune forget diff break-lock key cat completion version help" + local commands="init backup auth profile store source setup tui restore list ls prune forget diff break-lock key cat completion version help" local global_flags="-store -profile -profiles-file -s3-endpoint -s3-region -s3-profile -s3-access-key -s3-secret-key -source-sftp-password -source-sftp-key -source-sftp-known-hosts -source-sftp-insecure -store-sftp-password -store-sftp-key -store-sftp-known-hosts -store-sftp-insecure -encryption-key -password -recovery-key -kms-key-arn -kms-region -kms-endpoint -disable-packfile -prompt -no-prompt -verbose -quiet -json -debug" @@ -328,6 +328,7 @@ _cloudstic() { 'profile:Manage backup profiles' 'source:Discover source candidates for onboarding' 'setup:Guided setup and onboarding flows' + 'tui:Launch the interactive terminal dashboard' 'restore:Restore files from a backup snapshot' 'list:List all backup snapshots in the repository' 'ls:List files within a specific snapshot' @@ -776,6 +777,7 @@ complete -c cloudstic -n __fish_use_subcommand -a auth -d 'Manage reusable cloud complete -c cloudstic -n __fish_use_subcommand -a profile -d 'Manage backup profiles' complete -c cloudstic -n __fish_use_subcommand -a source -d 'Discover source candidates for onboarding' complete -c cloudstic -n __fish_use_subcommand -a setup -d 'Guided setup and onboarding flows' +complete -c cloudstic -n __fish_use_subcommand -a tui -d 'Launch the interactive terminal dashboard' complete -c cloudstic -n __fish_use_subcommand -a restore -d 'Restore files from a snapshot' complete -c cloudstic -n __fish_use_subcommand -a list -d 'List all backup snapshots' complete -c cloudstic -n __fish_use_subcommand -a ls -d 'List files within a snapshot' diff --git a/cmd/cloudstic/completion_test.go b/cmd/cloudstic/completion_test.go index 239b5c4..77829c5 100644 --- a/cmd/cloudstic/completion_test.go +++ b/cmd/cloudstic/completion_test.go @@ -21,7 +21,7 @@ func TestCompletionBash(t *testing.T) { "_cloudstic_query()", "complete -F _cloudstic cloudstic", // All commands are listed - "init", "backup", "auth", "profile", "store", "source", "setup", "restore", "list", "ls", "prune", "forget", + "init", "backup", "auth", "profile", "store", "source", "setup", "tui", "restore", "list", "ls", "prune", "forget", "diff", "break-lock", "key", "cat", "completion", // Key subcommands "list add-recovery passwd", @@ -69,6 +69,7 @@ func TestCompletionZsh(t *testing.T) { "backup:Create a new backup snapshot", "auth:Manage reusable cloud auth entries", "profile:Manage backup profiles", + "tui:Launch the interactive terminal dashboard", "new:Create or update a backup profile", "show:Show one profile and resolved store/auth references", "new:Create or update a reusable cloud auth entry", @@ -126,6 +127,7 @@ func TestCompletionFish(t *testing.T) { "complete -c cloudstic -n __fish_use_subcommand -a auth", "complete -c cloudstic -n __fish_use_subcommand -a source", "complete -c cloudstic -n __fish_use_subcommand -a setup", + "complete -c cloudstic -n __fish_use_subcommand -a tui", "complete -c cloudstic -n __fish_use_subcommand -a key", "complete -c cloudstic -n __fish_use_subcommand -a completion", // Key subcommands diff --git a/cmd/cloudstic/interactive.go b/cmd/cloudstic/interactive.go index 78d3051..0418282 100644 --- a/cmd/cloudstic/interactive.go +++ b/cmd/cloudstic/interactive.go @@ -11,12 +11,21 @@ import ( xterm "golang.org/x/term" ) +var isTerminalFunc = term.IsTerminal + func (r *runner) canPrompt() bool { stdin := r.stdin if stdin == nil { - stdin = os.Stdin + return false + } + stdout := r.stdoutFile + if stdout == nil { + return false + } + if isTerminalFunc == nil { + return !r.noPrompt } - return !r.noPrompt && term.IsTerminal(stdin.Fd()) && term.IsTerminal(os.Stdout.Fd()) + return !r.noPrompt && isTerminalFunc(stdin.Fd()) && isTerminalFunc(stdout.Fd()) } func (r *runner) promptLine(ctx context.Context, label, defaultValue string) (string, error) { diff --git a/cmd/cloudstic/main.go b/cmd/cloudstic/main.go index bef8d21..578a921 100644 --- a/cmd/cloudstic/main.go +++ b/cmd/cloudstic/main.go @@ -75,6 +75,8 @@ func runCmd(cmd string) int { return r.runSource(ctx) case "setup": return r.runSetup(ctx) + case "tui": + return r.runTUI(ctx) case "completion": runCompletion() return 0 diff --git a/cmd/cloudstic/runner.go b/cmd/cloudstic/runner.go index 5a00bcf..0112d9b 100644 --- a/cmd/cloudstic/runner.go +++ b/cmd/cloudstic/runner.go @@ -12,6 +12,7 @@ import ( type runner struct { out io.Writer errOut io.Writer + stdoutFile *os.File client cloudsticClient noPrompt bool stdin *os.File @@ -23,6 +24,7 @@ func newRunner() *runner { return &runner{ out: os.Stdout, errOut: os.Stderr, + stdoutFile: os.Stdout, noPrompt: hasGlobalFlag("no-prompt"), stdin: os.Stdin, runInteractiveCmd: defaultRunInteractiveCmd, diff --git a/cmd/cloudstic/store.go b/cmd/cloudstic/store.go index dda27d8..6db8ce0 100644 --- a/cmd/cloudstic/store.go +++ b/cmd/cloudstic/store.go @@ -46,6 +46,10 @@ func (g *globalFlags) applyDebug(s store.ObjectStore) store.ObjectStore { } func (g *globalFlags) openClient(ctx context.Context) (*cloudstic.Client, error) { + return g.openClientWithReporter(ctx, nil) +} + +func (g *globalFlags) openClientWithReporter(ctx context.Context, reporterOverride cloudstic.Reporter) (*cloudstic.Client, error) { if err := g.applyProfileStoreOverrides(); err != nil { return nil, err } @@ -57,15 +61,17 @@ func (g *globalFlags) openClient(ctx context.Context) (*cloudstic.Client, error) packfileEnabled := g.disablePackfile == nil || !*g.disablePackfile - var reporter cloudstic.Reporter - if *g.quiet || g.jsonEnabled() { - reporter = ui.NewNoOpReporter() - } else { - cr := ui.NewConsoleReporter() - if g.debugLog != nil { - cr.SetLogWriter(g.debugLog) + reporter := reporterOverride + if reporter == nil { + if *g.quiet || g.jsonEnabled() { + reporter = ui.NewNoOpReporter() + } else { + cr := ui.NewConsoleReporter() + if g.debugLog != nil { + cr.SetLogWriter(g.debugLog) + } + reporter = cr } - reporter = cr } kc, err := g.buildKeychain(ctx) diff --git a/cmd/cloudstic/tui_runtime.go b/cmd/cloudstic/tui_runtime.go new file mode 100644 index 0000000..672e7cd --- /dev/null +++ b/cmd/cloudstic/tui_runtime.go @@ -0,0 +1,292 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + + cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/engine" + "github.com/cloudstic/cli/internal/tui" + xterm "golang.org/x/term" +) + +var ( + tuiBuildDashboard = defaultBuildTUIDashboard + tuiRunProfileAction = defaultRunTUIProfileAction + tuiMakeRaw = xterm.MakeRaw + tuiRestoreTerminal = xterm.Restore + tuiGetTerminalSize = xterm.GetSize + tuiEnterAltScreen = defaultEnterAltScreen + 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 +} + +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) + 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 runTUIBackupAction(ctx, r, profilesFile, profile.Name, profileCfg, cfg, log) +} + +func loadTUIProfilesConfig(profilesFile string) (*cloudstic.ProfilesConfig, error) { + cfg, err := loadProfilesOrInit(profilesFile) + if err != nil { + return nil, err + } + ensureProfilesMaps(cfg) + return cfg, nil +} + +func runTUIInitAction(ctx context.Context, r *runner, 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 { + 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 { + base := &backupArgs{ + g: tuiStoreFlags(profilesFile, cloudstic.ProfileStore{}), + profile: profileName, + profilesFile: profilesFile, + flagsSet: map[string]bool{}, + } + *base.g.profilesFile = profilesFile + effective, err := mergeProfileBackupArgs(base, profileName, profileCfg, cfg) + if err != nil { + return err + } + client, err := effective.g.openClientWithReporter(ctx, log.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 { + return fmt.Errorf("backup failed") + } + return nil +} + +type tuiSession struct { + r *runner + profilesFile string + dashboard tui.Dashboard + stdin *os.File + stdinFD int + rawState *xterm.State + rawActive bool +} + +func newTUISession(r *runner, profilesFile string, dashboard tui.Dashboard) *tuiSession { + stdin := r.stdin + if stdin == nil { + stdin = os.Stdin + } + return &tuiSession{ + r: r, + profilesFile: profilesFile, + dashboard: ensureSelectedProfile(dashboard), + stdin: stdin, + stdinFD: int(stdin.Fd()), + rawActive: tuiMakeRaw != nil && tuiRestoreTerminal != nil, + } +} + +func (s *tuiSession) run(ctx context.Context) int { + if err := s.enter(); err != nil { + return s.r.fail("Failed to enter TUI screen: %v", err) + } + defer s.leave() + + if err := s.render(); err != nil { + return s.r.fail("Failed to render TUI: %v", err) + } + + eventCh := make(chan tuiAction, 32) + readErrCh := make(chan error, 1) + go s.readInput(eventCh, readErrCh) + + resizeCh := make(chan os.Signal, 1) + tuiNotifyResize(resizeCh) + defer tuiStopResize(resizeCh) + + for { + select { + case <-ctx.Done(): + return 0 + case <-resizeCh: + if err := s.render(); err != nil { + return s.r.fail("Failed to render TUI: %v", err) + } + case readErr := <-readErrCh: + if readErr != nil { + return 0 + } + case action, ok := <-eventCh: + if !ok { + return 0 + } + code, err := s.handleAction(ctx, action) + if err != nil { + return s.r.fail("%v", err) + } + if code >= 0 { + return code + } + } + } +} + +func (s *tuiSession) enter() error { + if tuiEnterAltScreen != nil { + if err := tuiEnterAltScreen(s.r.out); err != nil { + return err + } + } + if !s.rawActive { + return nil + } + state, err := tuiMakeRaw(s.stdinFD) + if err != nil { + return err + } + s.rawState = state + return nil +} + +func (s *tuiSession) leave() { + if s.rawActive && s.rawState != nil { + _ = tuiRestoreTerminal(s.stdinFD, s.rawState) + s.rawState = nil + } + if tuiLeaveAltScreen != nil { + _ = tuiLeaveAltScreen(s.r.out) + } +} + +func (s *tuiSession) suspendRaw() error { + if s.rawActive && s.rawState != nil { + if err := tuiRestoreTerminal(s.stdinFD, s.rawState); err != nil { + return err + } + s.rawState = nil + } + return nil +} + +func (s *tuiSession) resumeRaw() error { + if !s.rawActive || s.rawState != nil { + return nil + } + state, err := tuiMakeRaw(s.stdinFD) + if err != nil { + return err + } + s.rawState = state + return nil +} + +func (s *tuiSession) render() error { + return renderTUIScreenWidth(s.r.out, s.dashboard, tuiWidth(s.r)) +} + +func (s *tuiSession) readInput(eventCh chan<- tuiAction, readErrCh chan<- error) { + defer close(eventCh) + for { + event, err := readTUIAction(s.r.lineReader()) + if err != nil { + if err != io.EOF { + readErrCh <- err + } + return + } + eventCh <- event + } +} + +func (s *tuiSession) handleAction(ctx context.Context, action tuiAction) (int, error) { + switch action { + case tuiActionQuit: + return 0, nil + case tuiActionUp: + s.dashboard = moveTUISelection(s.dashboard, -1) + 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) + } + default: + return -1, nil + } + return -1, s.render() +} + +func (s *tuiSession) refresh(ctx context.Context) error { + selected := s.dashboard.SelectedProfile + activity := append([]string{}, s.dashboard.ActivityLines...) + dashboard, err := tuiBuildDashboard(ctx, s.profilesFile) + if err != nil { + return err + } + dashboard.SelectedProfile = selected + dashboard.ActivityLines = activity + s.dashboard = ensureSelectedProfile(dashboard) + return nil +} diff --git a/cmd/cloudstic/usage.go b/cmd/cloudstic/usage.go index 2cf745a..81bbf22 100644 --- a/cmd/cloudstic/usage.go +++ b/cmd/cloudstic/usage.go @@ -29,6 +29,7 @@ func printUsage() { {"store verify", "Verify one store's credentials and connectivity"}, {"source discover", "Discover local source candidates for onboarding"}, {"setup workstation", "Guide workstation onboarding and profile scaffolding"}, + {"tui", "Launch the interactive terminal dashboard"}, {"profile new", "Create or update a backup profile in profiles.yaml"}, {"profile list", "List stores, auth entries, and backup profiles"}, {"profile show", "Show one profile and resolved store/auth references"}, diff --git a/docs/user-guide.md b/docs/user-guide.md index 1349542..c3df782 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -17,6 +17,7 @@ Cloudstic is a content-addressable backup tool that creates encrypted, deduplica - [profile](#profile) - [store](#store) - [setup](#setup) + - [tui](#tui) - [restore](#restore) - [list](#list) - [ls](#ls) @@ -584,6 +585,34 @@ plan without a confirmation prompt. --- +### tui + +Launch the interactive terminal dashboard for configured profiles. + +```bash +# Launch the dashboard +cloudstic tui + +# Use a specific profiles file +cloudstic tui -profiles-file ~/.config/cloudstic/profiles.yaml +``` + +The TUI is intended for interactive operator workflows. It shows: + +- configured profiles and their current readiness +- the selected profile's source, store, auth, and latest backup metadata +- recent activity for in-TUI actions + +Current controls: + +- `↑` / `↓` or `j` / `k`: move selection +- `b`: run `backup` for the selected profile, or `init` if its store is not initialized +- `q`: quit + +`cloudstic tui` requires an interactive terminal. It is not intended for scripts or CI. + +--- + ### store Manage named store entries in `profiles.yaml`. Stores define storage backend, connection credentials, and encryption settings. diff --git a/e2e/feature_tui_test.go b/e2e/feature_tui_test.go new file mode 100644 index 0000000..552114d --- /dev/null +++ b/e2e/feature_tui_test.go @@ -0,0 +1,30 @@ +package e2e + +import ( + "strings" + "testing" +) + +func TestCLI_Feature_TUI_Help(t *testing.T) { + if !shouldRun(Hermetic) { + t.Skip("skipping hermetic test") + } + + bin := buildBinary(t) + out := run(t, bin, "tui", "--help") + if !strings.Contains(out, "Usage: cloudstic tui [options]") { + t.Fatalf("unexpected output:\n%s", out) + } +} + +func TestCLI_Feature_TUI_NonInteractiveGuardrail(t *testing.T) { + if !shouldRun(Hermetic) { + t.Skip("skipping hermetic test") + } + + bin := buildBinary(t) + out := runExpectFail(t, bin, "tui") + if !strings.Contains(out, "requires an interactive terminal") { + t.Fatalf("unexpected output:\n%s", out) + } +} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go new file mode 100644 index 0000000..55656f0 --- /dev/null +++ b/internal/tui/dashboard.go @@ -0,0 +1,265 @@ +package tui + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/cloudstic/cli/internal/core" + "github.com/cloudstic/cli/internal/engine" +) + +type Dashboard struct { + ProfileCount int + StoreCount int + AuthCount int + SelectedProfile string + ActivityLines []string + Profiles []ProfileCard +} + +type ProfileCard struct { + Name string + Source string + StoreRef string + AuthRef string + Enabled bool + Status string + StatusNote string + LastBackup string + LastRef string +} + +type StoreProbe struct { + Status string + Error string + Snapshots []engine.SnapshotEntry +} + +type SnapshotLoader func(context.Context, string, engine.ProfileStore) ([]engine.SnapshotEntry, error) + +func BuildDashboardFromConfig(ctx context.Context, cfg *engine.ProfilesConfig, load SnapshotLoader) Dashboard { + if cfg == nil { + cfg = &engine.ProfilesConfig{} + } + + probes := map[string]StoreProbe{} + if load != nil { + for name, storeCfg := range cfg.Stores { + snapshots, err := load(ctx, name, storeCfg) + if err != nil { + probes[name] = StoreProbe{ + Status: "error", + Error: err.Error(), + } + continue + } + probes[name] = StoreProbe{ + Status: "ok", + Snapshots: snapshots, + } + } + } + + return BuildDashboard(cfg, probes) +} + +func BuildDashboard(cfg *engine.ProfilesConfig, probes map[string]StoreProbe) Dashboard { + if cfg == nil { + cfg = &engine.ProfilesConfig{} + } + if probes == nil { + probes = map[string]StoreProbe{} + } + + names := make([]string, 0, len(cfg.Profiles)) + for name := range cfg.Profiles { + names = append(names, name) + } + sort.Strings(names) + + d := Dashboard{ + ProfileCount: len(cfg.Profiles), + StoreCount: len(cfg.Stores), + AuthCount: len(cfg.Auth), + Profiles: make([]ProfileCard, 0, len(cfg.Profiles)), + } + 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) + 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, + }) + } + return d +} + +func profileStatus(cfg *engine.ProfilesConfig, p engine.BackupProfile, probe StoreProbe) (string, string) { + if !p.IsEnabled() { + return "disabled", "profile disabled" + } + if p.Store == "" { + return "error", "no store ref" + } + if _, ok := cfg.Stores[p.Store]; !ok { + return "error", "missing store" + } + if p.AuthRef != "" { + auth, ok := cfg.Auth[p.AuthRef] + if !ok { + return "error", "missing auth ref" + } + if provider := profileProviderFromSource(p.Source); provider != "" && auth.Provider != "" && auth.Provider != provider { + return "error", "provider mismatch" + } + } + if provider := profileProviderFromSource(p.Source); provider != "" && p.AuthRef == "" { + return "error", "missing auth" + } + switch probe.Status { + case "error": + if probe.Error != "" { + return "warning", normalizeProbeError(probe.Error) + } + return "warning", "store unavailable" + case "ok": + if latest, _ := latestBackup(p.Source, probe.Snapshots); latest == "" { + return "ready", "never backed up" + } + } + return "ready", "" +} + +func latestBackup(sourceURI string, entries []engine.SnapshotEntry) (string, string) { + want := sourceKeyFromURI(sourceURI) + if want.Type == "" { + return "", "" + } + for _, entry := range entries { + if snapshotMatchesSource(entry.Snap.Source, want) { + if entry.Created.IsZero() { + return "unknown time", entry.Ref + } + return entry.Created.Local().Format("2006-01-02 15:04"), entry.Ref + } + } + return "", "" +} + +type sourceKey struct { + Type string + Path string + DriveName string +} + +func sourceKeyFromURI(raw string) sourceKey { + scheme, rest, ok := strings.Cut(raw, ":") + if !ok { + switch raw { + case "gdrive", "gdrive-changes", "onedrive", "onedrive-changes": + return sourceKey{Type: raw, Path: "/"} + default: + return sourceKey{} + } + } + + switch scheme { + case "local", "sftp": + return sourceKey{Type: scheme, Path: rest} + case "gdrive", "gdrive-changes", "onedrive", "onedrive-changes": + if strings.HasPrefix(rest, "//") { + remainder := strings.TrimPrefix(rest, "//") + host, path, _ := strings.Cut(remainder, "/") + return sourceKey{Type: scheme, DriveName: host, Path: ensureLeadingSlash(path)} + } + if rest == "" { + return sourceKey{Type: scheme, Path: "/"} + } + return sourceKey{Type: scheme, Path: ensureLeadingSlash(rest)} + default: + return sourceKey{} + } +} + +func snapshotMatchesSource(src *core.SourceInfo, want sourceKey) bool { + if src == nil || src.Type != want.Type { + return false + } + if want.DriveName != "" && src.DriveName != "" && src.DriveName != want.DriveName { + return false + } + if want.Path != "" && src.Path != want.Path { + return false + } + return true +} + +func profileProviderFromSource(sourceURI string) string { + switch sourceKeyFromURI(sourceURI).Type { + case "gdrive", "gdrive-changes": + return "google" + case "onedrive", "onedrive-changes": + return "onedrive" + default: + return "" + } +} + +func ensureLeadingSlash(path string) string { + if path == "" { + return "/" + } + if strings.HasPrefix(path, "/") { + return path + } + return "/" + path +} + +func normalizeProbeError(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if _, rest, ok := strings.Cut(raw, ": "); ok { + raw = rest + } + switch { + case strings.Contains(raw, "repository not initialized"): + return "repository not initialized" + default: + return raw + } +} + +func ProbeStatusLabel(kind string) string { + switch kind { + case "ready": + return "ready" + case "disabled": + return "disabled" + case "warning": + return "warning" + case "error": + return "error" + default: + return fmt.Sprintf("unknown:%s", kind) + } +} + +func SnapshotAgeLabel(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Local().Format("2006-01-02 15:04") +} diff --git a/internal/tui/dashboard_test.go b/internal/tui/dashboard_test.go new file mode 100644 index 0000000..66a7088 --- /dev/null +++ b/internal/tui/dashboard_test.go @@ -0,0 +1,160 @@ +package tui + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/cloudstic/cli/internal/core" + "github.com/cloudstic/cli/internal/engine" +) + +func TestBuildDashboard_SortsProfilesAndCountsSections(t *testing.T) { + enabled := true + disabled := false + cfg := &engine.ProfilesConfig{ + Stores: map[string]engine.ProfileStore{ + "remote": {URI: "s3:bucket/prod"}, + }, + Auth: map[string]engine.ProfileAuth{ + "google-work": {Provider: "google"}, + }, + Profiles: map[string]engine.BackupProfile{ + "zeta": { + Source: "local:/tmp/zeta", + Store: "remote", + Enabled: &disabled, + }, + "alpha": { + Source: "local:/tmp/alpha", + Store: "remote", + AuthRef: "google-work", + Enabled: &enabled, + }, + }, + } + + got := BuildDashboard(cfg, map[string]StoreProbe{ + "remote": { + Status: "ok", + Snapshots: []engine.SnapshotEntry{ + { + Ref: "snapshot/abc", + Created: mustTime(t, "2026-04-03T10:30:00Z"), + Snap: core.Snapshot{ + Source: &core.SourceInfo{Type: "local", Path: "/tmp/alpha"}, + }, + }, + }, + }, + }) + if got.ProfileCount != 2 || got.StoreCount != 1 || got.AuthCount != 1 { + t.Fatalf("unexpected counts: %+v", got) + } + if len(got.Profiles) != 2 { + t.Fatalf("profiles=%d want 2", len(got.Profiles)) + } + if got.Profiles[0].Name != "alpha" || got.Profiles[1].Name != "zeta" { + t.Fatalf("profiles not sorted: %+v", got.Profiles) + } + if !got.Profiles[0].Enabled { + t.Fatalf("alpha should be enabled") + } + if got.Profiles[1].Enabled { + t.Fatalf("zeta should be disabled") + } + 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" { + t.Fatalf("status = %q want ready", got.Profiles[0].Status) + } + if got.Profiles[1].Status != "disabled" { + t.Fatalf("status = %q want disabled", got.Profiles[1].Status) + } +} + +func TestBuildDashboard_NormalizesStoreProbeErrors(t *testing.T) { + cfg := &engine.ProfilesConfig{ + Stores: map[string]engine.ProfileStore{ + "1": {URI: "local:/tmp/store"}, + }, + Profiles: map[string]engine.BackupProfile{ + "desktop": { + Source: "local:/tmp/Desktop", + Store: "1", + }, + }, + } + + got := BuildDashboard(cfg, map[string]StoreProbe{ + "1": { + Status: "error", + Error: "1: repository not initialized -- run 'cloudstic init' first", + }, + }) + if len(got.Profiles) != 1 { + t.Fatalf("profiles=%d want 1", len(got.Profiles)) + } + if got.Profiles[0].Status != "warning" { + 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) + } +} + +func TestBuildDashboardFromConfig_LoadsStoreSnapshots(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 := BuildDashboardFromConfig(context.Background(), cfg, func(_ context.Context, name string, _ engine.ProfileStore) ([]engine.SnapshotEntry, error) { + if name != "remote" { + t.Fatalf("unexpected store %q", name) + } + return []engine.SnapshotEntry{{ + Ref: "snapshot/1", + Created: mustTime(t, "2026-04-03T10:00:00Z"), + Snap: core.Snapshot{ + Source: &core.SourceInfo{Type: "local", Path: "/docs"}, + }, + }}, nil + }) + if len(got.Profiles) != 1 || got.Profiles[0].LastRef != "snapshot/1" { + t.Fatalf("unexpected dashboard: %+v", got) + } +} + +func TestBuildDashboardFromConfig_StoreErrorBecomesWarning(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 := 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" { + t.Fatalf("unexpected profile status: %+v", got.Profiles[0]) + } +} + +func mustTime(t *testing.T, raw string) time.Time { + t.Helper() + got, err := time.Parse(time.RFC3339, raw) + if err != nil { + t.Fatalf("time.Parse: %v", err) + } + return got +} diff --git a/internal/tui/shell.go b/internal/tui/shell.go new file mode 100644 index 0000000..4c70ed1 --- /dev/null +++ b/internal/tui/shell.go @@ -0,0 +1,393 @@ +package tui + +import ( + "fmt" + "io" + "strings" + "unicode/utf8" + + "github.com/cloudstic/cli/internal/ui" +) + +type Rect struct { + X int + Y int + W int + H int +} + +type DashboardLayout struct { + ProfileRows map[int]string + ActionRect Rect +} + +func RenderDashboard(w io.Writer, d Dashboard) error { + return RenderDashboardWidth(w, d, 0) +} + +func RenderDashboardWidth(w io.Writer, d Dashboard, width int) error { + if _, err := fmt.Fprintf(w, "%s%s%s\n", ui.Bold, "Cloudstic TUI", ui.Reset); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "%sOperator dashboard for profiles, stores, and auth.%s\n", ui.Dim, ui.Reset); err != nil { + return err + } + if _, err := fmt.Fprintln(w); err != nil { + return err + } + + stats := []string{ + fmt.Sprintf("%sProfiles%s %d", ui.Cyan, ui.Reset, d.ProfileCount), + fmt.Sprintf("%sStores%s %d", ui.Cyan, ui.Reset, d.StoreCount), + fmt.Sprintf("%sAuth%s %d", ui.Cyan, ui.Reset, d.AuthCount), + } + if err := renderBoxExact(w, "Overview", []string{strings.Join(stats, " ")}, panelWidth(width)); err != nil { + return err + } + + profilesWidth, detailWidth := splitPaneWidths(width) + leftLines := renderProfileList(d) + rightLines := renderSelectedProfile(d) + leftLines, rightLines = equalizePaneHeights(leftLines, rightLines) + if err := renderColumns(w, + boxLinesExact("Profiles", leftLines, profilesWidth), + boxLinesExact("Selection", rightLines, detailWidth), + width, + ); err != nil { + 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 { + 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) + return err +} + +func LayoutDashboardWidth(d Dashboard, width int) DashboardLayout { + layout := DashboardLayout{ProfileRows: map[int]string{}} + y := 1 + y += 3 // title, subtitle, blank + y += len(boxLinesExact("Overview", []string{ + fmt.Sprintf("%sProfiles%s %d %sStores%s %d %sAuth%s %d", ui.Cyan, ui.Reset, d.ProfileCount, ui.Cyan, ui.Reset, d.StoreCount, ui.Cyan, ui.Reset, d.AuthCount), + }, panelWidth(width))) + + profilesWidth, detailWidth := splitPaneWidths(width) + leftLines := renderProfileList(d) + rightLines := renderSelectedProfile(d) + leftLines, rightLines = equalizePaneHeights(leftLines, rightLines) + leftBox := boxLinesExact("Profiles", leftLines, profilesWidth) + + rightStartX := longestVisible(leftBox) + 3 + contentStartY := y + 3 + for i, profile := range d.Profiles { + layout.ProfileRows[contentStartY+i] = profile.Name + } + actionRow := len(rightLines) - 1 + if actionRow >= 0 { + layout.ActionRect = Rect{ + X: rightStartX + 2, + Y: contentStartY + actionRow, + W: detailWidth, + H: 1, + } + } + return layout +} + +func renderBoxExact(w io.Writer, title string, lines []string, width int) error { + for _, line := range boxLinesExact(title, lines, width) { + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + } + return nil +} + +func boxLinesExact(title string, lines []string, width int) []string { + titleLine := fmt.Sprintf("%s%s%s", ui.Bold, title, ui.Reset) + if width <= 0 { + width = visibleLen(titleLine) + for _, line := range lines { + if l := visibleLen(line); l > width { + width = l + } + } + } + if width < visibleLen(titleLine) { + width = visibleLen(titleLine) + } + innerWidth := width + 2 + out := []string{"┌" + strings.Repeat("─", innerWidth) + "┐"} + titlePadding := width - visibleLen(titleLine) + out = append(out, fmt.Sprintf("│ %s%s │", titleLine, strings.Repeat(" ", titlePadding))) + if len(lines) > 0 { + out = append(out, fmt.Sprintf("│ %s │", strings.Repeat(" ", width))) + } + for _, line := range lines { + line = truncateVisible(line, width) + padding := width - visibleLen(line) + out = append(out, fmt.Sprintf("│ %s%s │", line, strings.Repeat(" ", padding))) + } + out = append(out, "└"+strings.Repeat("─", innerWidth)+"┘") + return out +} + +func renderColumns(w io.Writer, left, right []string, maxWidth int) error { + leftWidth := longestVisible(left) + rightWidth := longestVisible(right) + height := len(left) + if len(right) > height { + height = len(right) + } + for i := 0; i < height; i++ { + leftLine := paddedLine(left, i, leftWidth) + rightLine := paddedLine(right, i, rightWidth) + if _, err := fmt.Fprintf(w, "%s %s\n", leftLine, rightLine); err != nil { + return err + } + } + return nil +} + +func renderProfileList(d Dashboard) []string { + if len(d.Profiles) == 0 { + return []string{fmt.Sprintf("%sNo profiles configured.%s", ui.Dim, ui.Reset)} + } + lines := make([]string, 0, len(d.Profiles)) + for _, profile := range d.Profiles { + lines = append(lines, profileHeaderLine(profile, profile.Name == d.SelectedProfile)) + } + return lines +} + +func renderSelectedProfile(d Dashboard) []string { + profile, ok := selectedProfileCard(d) + if !ok { + return []string{fmt.Sprintf("%sNo profile selected.%s", ui.Dim, ui.Reset)} + } + lines := []string{ + fmt.Sprintf("%s%s%s", ui.Bold, profile.Name, ui.Reset), + profileDetailLine("State", plainProfileStateLabel(profile)), + profileDetailLine("Source", profile.Source), + profileDetailLine("Store", profile.StoreRef), + } + 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": + 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") { + lines = append(lines, profileDetailLine("Status", profile.StatusNote)) + } + lines = append(lines, "") + lines = append(lines, fmt.Sprintf("%sAction%s %s", ui.Dim, ui.Reset, selectedActionLabel(profile))) + return lines +} + +func profileHeaderLine(profile ProfileCard, selected bool) string { + prefix := " " + if selected { + prefix = fmt.Sprintf("%s› %s", ui.Cyan, ui.Reset) + } + return fmt.Sprintf("%s%s%s%s [%s]", prefix, ui.Bold, profile.Name, ui.Reset, profileStateLabel(profile)) +} + +func profileDetailLine(label, value string) string { + return fmt.Sprintf(" %s%-6s%s %s", ui.Dim, label, ui.Reset, value) +} + +func splitPaneWidths(total int) (int, int) { + if total <= 0 { + total = 100 + } + available := total - 10 // two box borders/padding (+4 each) plus 2 spaces between columns + if available < 40 { + available = 40 + } + left := available / 3 + if left < 24 { + left = 24 + } + right := available - left + if right < 36 { + right = 36 + } + if left+right > available { + left = available - right + if left < 24 { + left = 24 + right = available - left + } + } + return left, right +} + +func panelWidth(total int) int { + if total <= 0 { + total = 100 + } + width := total - 4 + if width < 20 { + return 20 + } + return width +} + +func longestVisible(lines []string) int { + width := 0 + for _, line := range lines { + if l := visibleLen(line); l > width { + width = l + } + } + return width +} + +func paddedLine(lines []string, idx, width int) string { + if idx >= len(lines) { + return strings.Repeat(" ", width) + } + line := lines[idx] + padding := width - visibleLen(line) + if padding < 0 { + padding = 0 + } + return line + strings.Repeat(" ", padding) +} + +func equalizePaneHeights(left, right []string) ([]string, []string) { + target := len(left) + if len(right) > target { + target = len(right) + } + for len(left) < target { + left = append(left, "") + } + for len(right) < target { + right = append(right, "") + } + return left, right +} + +func visibleLen(s string) int { + n := 0 + inEscape := false + for i := 0; i < len(s); { + switch { + case s[i] == '\x1b': + inEscape = true + i++ + case inEscape && s[i] == 'm': + inEscape = false + i++ + case !inEscape: + _, size := utf8.DecodeRuneInString(s[i:]) + n++ + i += size + default: + i++ + } + } + return n +} + +func truncateVisible(s string, limit int) string { + if limit <= 0 || visibleLen(s) <= limit { + return s + } + if limit == 1 { + return "…" + } + var b strings.Builder + visible := 0 + inEscape := false + for i := 0; i < len(s); { + switch { + case s[i] == '\x1b': + inEscape = true + b.WriteByte(s[i]) + i++ + case inEscape: + b.WriteByte(s[i]) + if s[i] == 'm' { + inEscape = false + } + i++ + default: + if visible >= limit-1 { + b.WriteRune('…') + b.WriteString(ui.Reset) + return b.String() + } + r, size := utf8.DecodeRuneInString(s[i:]) + b.WriteRune(r) + visible++ + i += size + } + } + return b.String() +} + +func profileStateLabel(profile ProfileCard) string { + switch profile.Status { + case "disabled": + return fmt.Sprintf("%sdisabled%s", ui.Dim, ui.Reset) + case "warning": + return fmt.Sprintf("%swarning%s", ui.Cyan, ui.Reset) + case "error": + return "error" + default: + if profile.Enabled { + return fmt.Sprintf("%senabled%s", ui.Green, ui.Reset) + } + return fmt.Sprintf("%sdisabled%s", ui.Dim, ui.Reset) + } +} + +func plainProfileStateLabel(profile ProfileCard) string { + switch profile.Status { + case "disabled", "warning", "error": + return profile.Status + default: + if profile.Enabled { + return "enabled" + } + return "disabled" + } +} + +func selectedProfileCard(d Dashboard) (ProfileCard, bool) { + for _, profile := range d.Profiles { + if profile.Name == d.SelectedProfile { + return profile, true + } + } + if len(d.Profiles) == 0 { + return ProfileCard{}, false + } + 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" + } + return "Press b to run backup" +} + +func trimSnapshotRef(ref string) string { + return strings.TrimPrefix(ref, "snapshot/") +} diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go new file mode 100644 index 0000000..096d820 --- /dev/null +++ b/internal/tui/shell_test.go @@ -0,0 +1,58 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestRenderDashboard(t *testing.T) { + d := Dashboard{ + ProfileCount: 1, + StoreCount: 1, + AuthCount: 0, + 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", + }, + }, + } + + var out strings.Builder + if err := RenderDashboard(&out, d); err != nil { + t.Fatalf("RenderDashboard: %v", err) + } + got := out.String() + for _, want := range []string{ + "Cloudstic TUI", + "Operator dashboard for profiles, stores, and auth.", + "Overview", + "Profiles", + "Activity", + "Stores", + "Auth", + "documents", + "›", + "enabled", + "Source", + "local:/Users/test/Documents", + "Store", + "remote", + "Backup", + "2026-04-03 11:05", + "Ref", + "abc123", + "No recent activity.", + "Use ↑/↓ to select a profile. Press b to backup or init. Press q to quit.", + } { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in output:\n%s", want, got) + } + } +}