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
17 changes: 17 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@ Run 'bak restore --dry-run <id>' to preview before applying.`,
// and stdout is a terminal. Cobra handles --help before RunE, so
// `bak --help` still shows cobra help regardless of TTY.
if len(args) == 0 && isTTY() {
preset, backupPath := tuiStatusBarInfo()
deps := tui.Deps{
Version: Version,
Preset: preset,
BackupPath: backupPath,
ConfigExists: configExists,
ListBackups: listBackups,
RunBackup: tuiRunBackup,
Expand Down Expand Up @@ -71,6 +74,20 @@ func configExists() bool {
return err == nil
}

// tuiStatusBarInfo returns the active preset and backup directory for the
// persistent TUI status bar (tui-personality REQ-TP-003). Both are best-effort:
// on any error the empty string is returned and the status bar omits that
// segment rather than failing the launch.
func tuiStatusBarInfo() (preset, backupPath string) {
if cfg, err := config.Load(); err == nil && cfg.Settings.DefaultPreset != "" {
preset = cfg.Settings.DefaultPreset
}
if dir, err := backup.BakDir(); err == nil {
backupPath = dir
}
return preset, backupPath
}

// Execute runs the root command.
func Execute() {
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
Expand Down
79 changes: 79 additions & 0 deletions internal/tui/components/statusbar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Package components provides reusable, pure render functions for TUI
// components. statusbar.go renders the persistent one-line status bar shown at
// the bottom of every screen (tui-personality REQ-TP-003).
package components

import (
"charm.land/lipgloss/v2"

"github.com/danielxxomg/bak-cli/internal/tui/styles"
)

// statusBarSeparator joins the status bar segments.
const statusBarSeparator = " • "

// statusBarHiddenBelow is the minimum terminal width (in columns) below which
// the status bar is hidden to avoid overflow. Matches the logo threshold.
const statusBarHiddenBelow = 40

// RenderStatusBar renders a one-line status bar containing the version,
// active preset, and backup path. It is a stateless pure function styled with
// the package-level StatusBarStyle (AGENTS.md §styles).
//
// The bar is hidden (returns "") when width is below 40 columns. When the
// backup path is too long to fit, it is truncated with an ellipsis ("…") so
// the whole bar stays within the terminal width.
func RenderStatusBar(width int, version, preset, path string) string {
if width < statusBarHiddenBelow {
return ""
}

// Leading segment: app name + version + preset.
left := "bak"
if version != "" {
left += " v" + version
}
if preset != "" {
left += statusBarSeparator + preset
}

full := left
if path != "" {
full += statusBarSeparator + path
}

// Fits as-is.
if lipgloss.Width(full) <= width {
return styles.StatusBarStyle.Render(full)
}

// Doesn't fit: truncate the path segment (spec: path truncated with
// ellipsis). If there's no path, truncate the leading segment.
if path == "" {
return styles.StatusBarStyle.Render(truncateEllipsis(left, width))
}

avail := width - lipgloss.Width(left) - len(statusBarSeparator)
if avail <= 0 {
// No room for the path at all; show the (possibly truncated) lead.
return styles.StatusBarStyle.Render(truncateEllipsis(left, width))
}
return styles.StatusBarStyle.Render(left + statusBarSeparator + truncateEllipsis(path, avail))
}

// truncateEllipsis truncates s to max visible columns, appending an ellipsis
// ("…") when truncation occurs. It is rune-aware so multi-byte paths truncate
// cleanly. Returns s unchanged when it already fits.
func truncateEllipsis(s string, max int) string {
if max <= 0 {
return ""
}
r := []rune(s)
if len(r) <= max {
return s
}
if max == 1 {
return "…"
}
return string(r[:max-1]) + "…"
}
116 changes: 116 additions & 0 deletions internal/tui/components/statusbar_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Package components provides reusable, pure render functions for TUI
// components. This file contains TDD tests for the persistent status bar
// (tui-personality REQ-TP-003), written BEFORE the production code.
package components

import (
"strings"
"testing"

"charm.land/lipgloss/v2"
)

func TestRenderStatusBar(t *testing.T) { //nolint:paralleltest // shared styles/colorprofile global state
tests := []struct {
name string
width int
version string
preset string
path string
wantEmpty bool
contains []string
notContain []string
wantMaxW int // visible width must be <= wantMaxW (0 = skip width check)
}{
{
name: "wide terminal shows all segments",
width: 80,
version: "1.0.0",
preset: "default",
path: "/home/user/.bak/backups",
contains: []string{"bak v1.0.0", "default", "/home/user/.bak/backups"},
wantMaxW: 80,
},
{
name: "narrow below 40 hidden",
width: 39,
version: "1.0.0",
preset: "default",
path: "/x",
wantEmpty: true,
},
{
name: "exactly 40 columns shown",
width: 40,
version: "1.0.0",
contains: []string{"bak v1.0.0"},
wantMaxW: 40,
},
{
name: "long path truncated with ellipsis",
width: 60,
version: "1.0.0",
preset: "default",
path: strings.Repeat("a", 50),
contains: []string{"bak v1.0.0", "default", "…"},
notContain: []string{strings.Repeat("a", 50)}, // full path must not fit
wantMaxW: 60,
},
{
name: "empty version still shows bak",
width: 80,
version: "",
preset: "",
path: "",
contains: []string{"bak"},
},
{
name: "no preset no path omits separator",
width: 80,
version: "1.0.0",
preset: "",
path: "",
contains: []string{"bak v1.0.0"},
notContain: []string{"•"},
},
{
name: "path only segment",
width: 80,
version: "",
preset: "",
path: "/var/bak",
contains: []string{"/var/bak"},
},
}

for _, tt := range tests { //nolint:paralleltest // subtests share table/struct state
t.Run(tt.name, func(t *testing.T) { //nolint:paralleltest // subtests share table/struct state
got := RenderStatusBar(tt.width, tt.version, tt.preset, tt.path)

if tt.wantEmpty {
if got != "" {
t.Errorf("RenderStatusBar(%d,…) = %q, want empty (hidden <40)", tt.width, got)
}
return
}
if got == "" {
t.Fatalf("RenderStatusBar(%d,…) returned empty, want non-empty", tt.width)
}
for _, want := range tt.contains {
if !strings.Contains(got, want) {
t.Errorf("output %q must contain %q", got, want)
}
}
for _, notWant := range tt.notContain {
if strings.Contains(got, notWant) {
t.Errorf("output %q must NOT contain %q", got, notWant)
}
}
if tt.wantMaxW > 0 {
if w := lipgloss.Width(got); w > tt.wantMaxW {
t.Errorf("visible width = %d, want <= %d (output %q)", w, tt.wantMaxW, got)
}
}
})
}
}
8 changes: 8 additions & 0 deletions internal/tui/deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ type Deps struct {
// Version is the application version string shown in the UI.
Version string

// Preset is the active backup preset name shown in the status bar.
// Empty when no preset is configured (the bar omits the segment).
Preset string

// BackupPath is the local backups directory shown (truncated) in the
// status bar. Empty when unresolved (the bar omits the segment).
BackupPath string

// ListBackups returns all known backups. May be nil during testing.
ListBackups func() ([]BackupInfo, error)

Expand Down
59 changes: 59 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package tui

import (
"fmt"

"charm.land/lipgloss/v2"

"github.com/danielxxomg/bak-cli/internal/tui/components"
Expand Down Expand Up @@ -618,9 +620,61 @@ func (m Model) View() tea.View {
}
v := tea.NewView(content)
v.AltScreen = true
v.WindowTitle = titleForScreen(m)
return v
}

// titleForScreen maps the active screen to a contextual terminal window title
// of the form "bak — {Screen}" (REQ-TP-001). On ScreenProgress with a running
// operation it appends the live step counter ("bak — Backup 3/7"); on
// ScreenRestore it appends the selected backup id when present. The title is a
// pure read of current model state, recomputed every render — no command
// plumbing, matching the existing v.AltScreen field-assignment pattern.
func titleForScreen(m Model) string {
switch m.screen {
case ScreenMenu:
return "bak — Main Menu"
case ScreenWelcome:
return "bak — Welcome"
case ScreenDashboard:
return "bak — Backups"
case ScreenSettings:
return "bak — Settings"
case ScreenCloud:
return "bak — Cloud"
case ScreenShortcuts:
return "bak — Shortcuts"
case ScreenHealth:
return "bak — Health"
case ScreenProfiles:
return "bak — Profiles"
case ScreenProgress:
return progressTitle(m)
case ScreenRestore:
return restoreTitle(m)
default:
return "bak"
}
}

// progressTitle renders the progress screen title, appending the live step
// counter ("bak — Backup 3/7") when an operation is running with a known total.
func progressTitle(m Model) string {
if m.progress != nil && m.progress.Running() && m.progress.Total > 0 {
return fmt.Sprintf("bak — Backup %d/%d", m.progress.Current, m.progress.Total)
}
return "bak — Backup"
}

// restoreTitle renders the restore screen title, appending the selected backup
// id ("bak — Restore:abc1234") when one has been chosen.
func restoreTitle(m Model) string {
if m.restore != nil && m.restore.SelectedID != "" {
return "bak — Restore:" + m.restore.SelectedID
}
return "bak — Restore"
}

// renderContent renders the active screen with optional help and toast
// overlays. It is the non-tooSmall branch of View, extracted to keep View's
// nesting shallow.
Expand All @@ -630,6 +684,11 @@ func (m Model) renderContent() string {
if m.showHelp {
content = screens.RenderShortcuts(m.width)
}
// Persistent status bar at the bottom of every screen (REQ-TP-003).
// Hidden on narrow terminals (<40 cols) by RenderStatusBar itself.
if bar := components.RenderStatusBar(m.width, m.deps.Version, m.deps.Preset, m.deps.BackupPath); bar != "" {
content += "\n" + bar
}
// Render toast overlay. On wide terminals (>= 50 cols), position
// the toast at bottom-right using lipgloss.Place. On narrow terminals,
// fall back to inline append below the screen content.
Expand Down
Loading
Loading