diff --git a/internal/tui/components/empty_state.go b/internal/tui/components/empty_state.go new file mode 100644 index 0000000..dfca475 --- /dev/null +++ b/internal/tui/components/empty_state.go @@ -0,0 +1,44 @@ +// Package components provides reusable, pure render functions for TUI +// components. empty_state.go renders the styled empty-state block shown when a +// screen has no data (tui-personality REQ-TP-007). +package components + +import ( + "strings" + + "github.com/danielxxomg/bak-cli/internal/tui/styles" +) + +// RenderEmptyState renders a styled empty-state block composed of an icon, an +// italic message, and a hint describing the next action. It is a stateless +// pure function styled with the package-level EmptyState styles (AGENTS.md +// styles section). All three segments are optional; when all are empty it +// returns the empty string. +// +// Example: RenderEmptyState(icon, "No backups yet", "Run bak backup to create one") +func RenderEmptyState(icon, message, hint string) string { + if icon == "" && message == "" && hint == "" { + return "" + } + + var b strings.Builder + switch { + case icon != "" && message != "": + b.WriteString(styles.EmptyStateIconStyle.Render(icon)) + b.WriteString(" ") + b.WriteString(styles.EmptyStateMsgStyle.Render(message)) + case icon != "": + b.WriteString(styles.EmptyStateIconStyle.Render(icon)) + default: + b.WriteString(styles.EmptyStateMsgStyle.Render(message)) + } + + if hint != "" { + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString(styles.EmptyStateHintStyle.Render(hint)) + } + + return b.String() +} diff --git a/internal/tui/components/empty_state_test.go b/internal/tui/components/empty_state_test.go new file mode 100644 index 0000000..c931e66 --- /dev/null +++ b/internal/tui/components/empty_state_test.go @@ -0,0 +1,76 @@ +// Package components provides reusable, pure render functions for TUI +// components. empty_state_test.go covers the styled empty-state renderer +// (tui-personality REQ-TP-007) written BEFORE the production code. +package components + +import ( + "strings" + "testing" +) + +// TestRenderEmptyState verifies the styled empty state renders the icon, the +// message, and the hint (REQ-TP-007). Table-driven so each call site's +// icon/message/hint triple is exercised. +func TestRenderEmptyState(t *testing.T) { //nolint:paralleltest // matches established codebase convention across all tui tests + tests := []struct { + name string + icon string + message string + hint string + }{ + { + name: "no backups", + icon: "∅", + message: "No backups yet", + hint: "Run 'bak backup' to create one", + }, + { + name: "no cloud provider", + icon: "☁", + message: "No cloud provider configured", + hint: "Run 'bak cloud login' to connect", + }, + { + name: "no restore targets", + icon: "∅", + message: "No backups to restore", + hint: "Run 'bak backup' to create one first", + }, + } + + for _, tt := range tests { //nolint:paralleltest // subtests share table/struct state + t.Run(tt.name, func(t *testing.T) { + out := RenderEmptyState(tt.icon, tt.message, tt.hint) + + if out == "" { + t.Fatal("RenderEmptyState returned empty string") + } + if !strings.Contains(out, tt.icon) { + t.Errorf("empty state missing icon %q: %q", tt.icon, out) + } + if !strings.Contains(out, tt.message) { + t.Errorf("empty state missing message %q: %q", tt.message, out) + } + if !strings.Contains(out, tt.hint) { + t.Errorf("empty state missing hint %q: %q", tt.hint, out) + } + }) + } +} + +// TestRenderEmptyState_NonEmptySegments triangulates that the renderer +// composes all three segments together (not just one) and is stable across +// repeated calls. +func TestRenderEmptyState_NonEmptySegments(t *testing.T) { //nolint:paralleltest // matches established codebase convention across all tui tests + out := RenderEmptyState("∅", "Nothing here", "do something") + + // All three pieces appear together in a single render. + if !strings.Contains(out, "∅") || !strings.Contains(out, "Nothing here") || !strings.Contains(out, "do something") { + t.Errorf("empty state did not compose all segments: %q", out) + } + + // Idempotent: a second call yields the same output. + if out2 := RenderEmptyState("∅", "Nothing here", "do something"); out2 != out { + t.Errorf("RenderEmptyState not idempotent: %q vs %q", out, out2) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index be70a3e..a9ce3d0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -308,6 +308,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case screens.ProgressDoneMsg: return m.handleProgressDoneMsg(msg) + + case tea.MouseWheelMsg, tea.MouseClickMsg: + // Mouse navigation is suppressed while the dashboard search input is + // active so wheel/click events can't move the table cursor behind the + // search box (REQ-TP-006). The guard lives on the root Model because + // that is where the search component state lives; DashboardModel has + // no search field. When search is inactive, fall through to the + // sub-model forwarding below so the dashboard handles the event. + if m.screen == ScreenDashboard && m.search.IsActive() { + return m, nil + } } // Forward remaining messages to the active sub-model. @@ -621,6 +632,14 @@ func (m Model) View() tea.View { v := tea.NewView(content) v.AltScreen = true v.WindowTitle = titleForScreen(m) + // Enable cell-motion mouse capture only on screens that handle mouse + // events (the dashboard). Other screens leave MouseMode at MouseModeNone + // so the terminal retains normal text-selection behavior (REQ-TP-006). + // v2 enables mouse declaratively via this View field — there is no + // tea.WithMouseCellMotion program option. + if m.screen == ScreenDashboard { + v.MouseMode = tea.MouseModeCellMotion + } return v } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 81a7c6b..9dbe7fe 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1067,8 +1067,8 @@ func TestModel_initDashboard_NilListBackups(t *testing.T) { //nolint:paralleltes // Dashboard should be usable (empty, no error). view := d.View().Content - if !strings.Contains(view, "No backups found") { - t.Errorf("nil ListBackups dashboard view = %q, want 'No backups found'", view) + if !strings.Contains(view, "No backups yet") { + t.Errorf("nil ListBackups dashboard view = %q, want 'No backups yet'", view) } } @@ -3036,3 +3036,109 @@ func TestModel_View_StatusBar(t *testing.T) { //nolint:paralleltest // shared st t.Errorf("narrow View() should hide status bar, but contains 'bak v1.0.0':\n%s", narrow) } } + +// ============================================================================= +// tui-personality Phase 4 — Mouse navigation (REQ-TP-006) +// RED: declarative v2 mouse mode + search-suppression guard. The mouse mode is +// set declaratively via the tea.View.MouseMode field (v2 API — there is no +// tea.WithMouseCellMotion program option in v2). The search guard lives on the +// root Model because that is where the search component state lives; +// DashboardModel has no search field (documented deviation from design.md). +// ============================================================================= + +// TestModel_View_MouseMode verifies the dashboard screen (the only screen that +// handles wheel/click) requests MouseModeCellMotion, while screens that don't +// handle mouse leave it at MouseModeNone so the terminal keeps normal +// text-selection behavior (REQ-TP-006). +func TestModel_View_MouseMode(t *testing.T) { //nolint:paralleltest // shared styles/colorprofile global state + tests := []struct { + name string + setup func() Model + want tea.MouseMode + }{ + {"menu disables mouse", func() Model { return modelAtScreen(ScreenMenu) }, tea.MouseModeNone}, + {"dashboard enables cell-motion mouse", func() Model { return modelAtScreen(ScreenDashboard) }, tea.MouseModeCellMotion}, + {"restore disables mouse (keyboard-only viewport)", func() Model { return modelAtScreen(ScreenRestore) }, tea.MouseModeNone}, + } + + 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 := tt.setup().View().MouseMode + if got != tt.want { + t.Errorf("%s: View().MouseMode = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +// TestModel_MouseSuppressedDuringSearch verifies mouse wheel/click events are +// dropped (dashboard view unchanged) while the dashboard search input is +// active, so scrolling the wheel behind the search box can't move the table +// cursor (REQ-TP-006 guard via search.IsActive()). The assertion is behavioral +// (rendered output identical before/after) per strict-tdd preference for +// user-visible behavior over internal cursor state. +func TestModel_MouseSuppressedDuringSearch(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending + m := NewModel(Deps{ + Version: "1.0.0", + ListBackups: func() ([]BackupInfo, error) { + return []BackupInfo{ + {ID: "row-1"}, {ID: "row-2"}, {ID: "row-3"}, + }, nil + }, + }) + m.width = 80 + m.height = 24 + // Navigate to dashboard and activate search. + m2, _ := m.Update(screenChangeMsg{screen: ScreenDashboard}) + m = m2.(Model) + m3, _ := m.Update(tea.KeyPressMsg{Code: '/'}) + m = m3.(Model) + if !m.search.IsActive() { + t.Fatal("search not active after '/' on dashboard") + } + + before := m.dashboard.View().Content + + // Wheel down during search: dashboard view must be unchanged. + nm, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + r := nm.(Model) + if r.dashboard.View().Content != before { + t.Errorf("wheel down during search changed dashboard view (should be suppressed)") + } + + // Left click during search: dashboard view must be unchanged. + nm, _ = r.Update(tea.MouseClickMsg{Button: tea.MouseLeft, Y: 2}) + r = nm.(Model) + if r.dashboard.View().Content != before { + t.Errorf("click during search changed dashboard view (should be suppressed)") + } +} + +// TestModel_MouseActiveWhenSearchInactive verifies the suppression guard is +// search-specific: with search INACTIVE, a mouse wheel event DOES change the +// dashboard view (cursor advances). Triangulates that the guard is not a +// blanket mouse block. +func TestModel_MouseActiveWhenSearchInactive(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending + m := NewModel(Deps{ + Version: "1.0.0", + ListBackups: func() ([]BackupInfo, error) { + return []BackupInfo{ + {ID: "row-1"}, {ID: "row-2"}, {ID: "row-3"}, + }, nil + }, + }) + m.width = 80 + m.height = 24 + m2, _ := m.Update(screenChangeMsg{screen: ScreenDashboard}) + m = m2.(Model) + if m.search.IsActive() { + t.Fatal("search should be inactive by default") + } + + before := m.dashboard.View().Content + nm, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + r := nm.(Model) + if r.dashboard.View().Content == before { + t.Error("wheel down with search inactive did not change dashboard view (cursor should advance)") + } +} diff --git a/internal/tui/screens/cloud.go b/internal/tui/screens/cloud.go index 564540a..aea2240 100644 --- a/internal/tui/screens/cloud.go +++ b/internal/tui/screens/cloud.go @@ -105,9 +105,9 @@ func RenderCloudStatus(info CloudInfo, width int) string { b.WriteString(styles.CloudTitleStyle.Render("Cloud Sync")) b.WriteString("\n\n") - // No provider configured. + // No provider configured (styled icon + message + hint, REQ-TP-007). if info.Provider == "" { - b.WriteString(styles.CloudEmptyStyle.Render("No cloud provider configured")) + b.WriteString(components.RenderEmptyState("\u2601", "No cloud provider configured", "Run 'bak cloud login' to connect")) b.WriteString("\n\n") b.WriteString(renderCloudHelp()) content := b.String() diff --git a/internal/tui/screens/cloud_test.go b/internal/tui/screens/cloud_test.go index 36ad100..5870781 100644 --- a/internal/tui/screens/cloud_test.go +++ b/internal/tui/screens/cloud_test.go @@ -74,6 +74,22 @@ func TestRenderCloudStatus_NoProvider(t *testing.T) { //nolint:paralleltest // n } } +// TestRenderCloudStatus_NoProvider_Styled verifies the no-provider branch +// renders the shared styled empty-state block (icon + message + hint) via +// components.RenderEmptyState, not a bare string (tui-personality REQ-TP-007). +func TestRenderCloudStatus_NoProvider_Styled(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending + info := CloudInfo{Provider: ""} + + output := RenderCloudStatus(info, 80) + + if !strings.Contains(output, "No cloud provider configured") { + t.Errorf("styled no-provider missing message: %q", output) + } + if !strings.Contains(output, "bak cloud login") { + t.Errorf("styled no-provider missing hint 'bak cloud login': %q", output) + } +} + // ============================================================================= // TestRenderCloudStatus_Counts — RED // ============================================================================= diff --git a/internal/tui/screens/dashboard.go b/internal/tui/screens/dashboard.go index 29e6dac..6034126 100644 --- a/internal/tui/screens/dashboard.go +++ b/internal/tui/screens/dashboard.go @@ -35,6 +35,22 @@ var dashboardColumns = []table.Column{ {Title: "Cloud", Width: 10}, } +// dashboardMouseHeaderOffset is the number of rendered lines above the table +// data rows (the "Backups" title + a blank line). A mouse click's Y coordinate +// is mapped to a table row index by subtracting this offset (REQ-TP-006). +const dashboardMouseHeaderOffset = 2 + +// clampCursor clamps cur into the valid table row range [0, rows-1]. +func clampCursor(cur, rows int) int { + if cur < 0 { + return 0 + } + if cur >= rows { + return rows - 1 + } + return cur +} + // DashboardModel is the Bubble Tea sub-model for the dashboard screen. // It wraps a bubbles/table sub-model and handles keyboard navigation. // @@ -107,6 +123,31 @@ func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.table = newTable return m, cmd } + + case tea.MouseWheelMsg: + // Mouse wheel scrolls the backup list (REQ-TP-006). + rows := len(m.table.Rows()) + if rows > 0 { + cur := m.table.Cursor() + switch msg.Button { + case tea.MouseWheelDown: + cur++ + case tea.MouseWheelUp: + cur-- + } + m.table.SetCursor(clampCursor(cur, rows)) + } + return m, nil + + case tea.MouseClickMsg: + // Left click selects the clicked row (REQ-TP-006). + if msg.Button == tea.MouseLeft { + rows := len(m.table.Rows()) + if rows > 0 { + m.table.SetCursor(clampCursor(msg.Y-dashboardMouseHeaderOffset, rows)) + } + } + return m, nil } // Forward all other messages to the table sub-model. @@ -166,9 +207,9 @@ func (m DashboardModel) View() tea.View { return tea.NewView(b.String()) } - // Empty state. + // Empty state (styled icon + message + hint, REQ-TP-007). if len(m.table.Rows()) == 0 { - b.WriteString(styles.DashboardEmptyStyle.Render("No backups found")) + b.WriteString(components.RenderEmptyState("∅", "No backups yet", "Run 'bak backup' to create one")) b.WriteString("\n\n") b.WriteString(renderDashboardHelp()) return tea.NewView(b.String()) diff --git a/internal/tui/screens/dashboard_test.go b/internal/tui/screens/dashboard_test.go index 21f2473..db862a3 100644 --- a/internal/tui/screens/dashboard_test.go +++ b/internal/tui/screens/dashboard_test.go @@ -240,8 +240,8 @@ func TestDashboard_View_EmptyState(t *testing.T) { //nolint:paralleltest // not output := m.View().Content - if !strings.Contains(output, "No backups found") { - t.Errorf("View() output %q does not contain 'No backups found'", output) + if !strings.Contains(output, "No backups yet") { + t.Errorf("View() output %q does not contain 'No backups yet'", output) } if len(output) == 0 { @@ -249,6 +249,27 @@ func TestDashboard_View_EmptyState(t *testing.T) { //nolint:paralleltest // not } } +// TestDashboard_View_EmptyState_Styled verifies the empty dashboard renders the +// shared styled empty-state block (icon + message + hint) via +// components.RenderEmptyState, not a bare string (tui-personality REQ-TP-007). +// The hint presence is the behavioral proof: a bare message string has none. +func TestDashboard_View_EmptyState_Styled(t *testing.T) { //nolint:paralleltest // matches established codebase convention across all tui tests + m := NewDashboardModel(func() ([]BackupInfo, error) { + return []BackupInfo{}, nil + }) + m.width = 80 + m.height = 24 + + output := m.View().Content + + if !strings.Contains(output, "No backups yet") { + t.Errorf("styled empty state missing message 'No backups yet': %q", output) + } + if !strings.Contains(output, "bak backup") { + t.Errorf("styled empty state missing hint 'bak backup': %q", output) + } +} + // ============================================================================= // TestDashboard_View_ErrorState — RED // ============================================================================= @@ -364,12 +385,90 @@ func TestDashboard_View_SingleRow(t *testing.T) { //nolint:paralleltest // not y } // ============================================================================= -// Error type used in tests — matches NewDashboardModel tests. +// Phase 4: Mouse Navigation (PR 2 — Tier 2b) — RED // ============================================================================= -// ============================================================================= -// Phase 2 Search → Dashboard Wiring Tests — RED (SetFilter does not exist yet) -// ============================================================================= +// TestDashboard_MouseWheel_ScrollsList verifies the mouse wheel advances and +// retreats the table cursor (REQ-TP-006: wheel scrolls the list). +func TestDashboard_MouseWheel_ScrollsList(t *testing.T) { //nolint:paralleltest // matches established codebase convention across all tui tests + m := NewDashboardModel(func() ([]BackupInfo, error) { + return []BackupInfo{{ID: "1"}, {ID: "2"}, {ID: "3"}}, nil + }) + m.width = 80 + m.height = 24 + + // Wheel down advances the cursor (0 -> 1). + before := m.table.Cursor() + nm, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + r := nm.(DashboardModel) + if r.table.Cursor() <= before { + t.Errorf("wheel down did not advance cursor: %d -> %d", before, r.table.Cursor()) + } + + // Wheel up retreats the cursor (1 -> 0). + before = r.table.Cursor() + nm, _ = r.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + r = nm.(DashboardModel) + if r.table.Cursor() >= before { + t.Errorf("wheel up did not retreat cursor: %d -> %d", before, r.table.Cursor()) + } +} + +// TestDashboard_MouseClick_SelectsRow verifies a left click moves the cursor +// to the clicked row (REQ-TP-006: click selects the clicked row). +func TestDashboard_MouseClick_SelectsRow(t *testing.T) { //nolint:paralleltest // matches established codebase convention across all tui tests + m := NewDashboardModel(func() ([]BackupInfo, error) { + return []BackupInfo{{ID: "1"}, {ID: "2"}, {ID: "3"}}, nil + }) + m.width = 80 + m.height = 24 + + tests := []struct { + name string + y int + wantCur int + }{ + {"click first data row", 2, 0}, + {"click second data row", 3, 1}, + {"click third data row", 4, 2}, + } + + for _, tt := range tests { //nolint:paralleltest // subtests share table/struct state + t.Run(tt.name, func(t *testing.T) { + nm, _ := m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, Y: tt.y}) + r := nm.(DashboardModel) + if r.table.Cursor() != tt.wantCur { + t.Errorf("click at Y=%d: cursor = %d, want %d", tt.y, r.table.Cursor(), tt.wantCur) + } + }) + } +} + +// TestDashboard_MouseWheel_ClampsAtBounds verifies wheel scrolling clamps at +// the first/last row instead of going out of bounds. +func TestDashboard_MouseWheel_ClampsAtBounds(t *testing.T) { //nolint:paralleltest // matches established codebase convention across all tui tests + m := NewDashboardModel(func() ([]BackupInfo, error) { + return []BackupInfo{{ID: "1"}, {ID: "2"}, {ID: "3"}}, nil + }) + m.width = 80 + m.height = 24 + + // Wheel up from the top stays at 0. + nm, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + r := nm.(DashboardModel) + if r.table.Cursor() != 0 { + t.Errorf("wheel up at top: cursor = %d, want 0", r.table.Cursor()) + } + + // Wheel down past the last row clamps at the last row (index 2). + for i := 0; i < 10; i++ { + nm, _ = r.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + r = nm.(DashboardModel) + } + if r.table.Cursor() != 2 { + t.Errorf("wheel down past end: cursor = %d, want 2 (clamped)", r.table.Cursor()) + } +} // TestDashboard_SetFilter_MatchingRows verifies that SetFilter with a // matching query returns only rows containing that substring (case-insensitive). @@ -513,8 +612,8 @@ func TestDashboard_View_HelpBar_Empty(t *testing.T) { //nolint:paralleltest // n output := m.View().Content // Must show empty state AND help bar. - if !strings.Contains(output, "No backups found") { - t.Errorf("empty dashboard missing 'No backups found': %q", output) + if !strings.Contains(output, "No backups yet") { + t.Errorf("empty dashboard missing 'No backups yet': %q", output) } if !strings.Contains(output, "navigate") { t.Errorf("empty dashboard help bar missing 'navigate': %q", output) diff --git a/internal/tui/screens/restore.go b/internal/tui/screens/restore.go index dd2d255..eeffee9 100644 --- a/internal/tui/screens/restore.go +++ b/internal/tui/screens/restore.go @@ -1,9 +1,11 @@ package screens import ( + "errors" "fmt" "strings" + "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "github.com/danielxxomg/bak-cli/internal/tui/components" @@ -57,6 +59,12 @@ type RestoreModel struct { SelectedID string // DryRunOutput holds the diff preview text. DryRunOutput string + // viewport renders the dry-run diff in a bounded, scrollable region + // (REQ-TP-005). It is the sole presentation surface for dry-run content on + // restoreStateDryRun — the raw diff is never dumped to the screen body. + viewport viewport.Model + // vpReady reports whether the viewport has been sized via WindowSizeMsg. + vpReady bool // Err holds the last error. Err error @@ -78,6 +86,7 @@ func NewRestoreModel( Cursor: 0, listBackups: listBackups, runRestore: runRestore, + viewport: viewport.New(), } } @@ -98,6 +107,11 @@ func (m RestoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.Width = msg.Width m.Height = msg.Height + // Size the embedded viewport so the dry-run diff is bounded to the + // available terminal height (header + footer excluded). + m.viewport.SetWidth(msg.Width) + m.viewport.SetHeight(dryRunViewportHeight(msg.Height)) + m.vpReady = true return m, nil case restoreBackupsLoadedMsg: @@ -114,6 +128,9 @@ func (m RestoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.DryRunOutput = msg.output + // The viewport is the sole presentation surface for dry-run content + // (REQ-TP-005 / restore-flow delta): SetContent replaces the raw dump. + m.viewport.SetContent(msg.output) m.State = restoreStateDryRun return m, nil @@ -154,6 +171,16 @@ func (m RestoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m RestoreModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + // When the confirm modal is active, forward key events to it so the user + // can confirm/cancel via the keyboard (the modal owns Enter/Esc/Tab); + // otherwise the modal renders but is non-interactive. + if m.Modal != nil { + newModal, cmd := m.Modal.Update(msg) + m2 := newModal.(components.ModalModel) + m.Modal = &m2 + return m, cmd + } + switch m.State { case restoreStateList: switch msg.Code { @@ -176,18 +203,7 @@ func (m RestoreModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } case restoreStateDryRun: - switch msg.Code { - case 'q', 27: - m.State = restoreStateList - return m, nil - case '\r': - m.State = restoreStateConfirm - modal := components.NewModal("Confirm Restore", - fmt.Sprintf("Restore backup %s? This will overwrite current config.", m.SelectedID), - []string{"Confirm", "Cancel"}) - m.Modal = &modal - return m, nil - } + return m.handleDryRunKey(msg) case restoreStateDone: switch msg.Code { @@ -201,6 +217,38 @@ func (m RestoreModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } +// handleDryRunKey routes keystrokes in the dry-run preview state: q/Esc +// returns to the backup list, g/G jump the viewport to top/bottom (vim-style, +// not in the viewport default keymap), enter opens the confirm modal, and all +// other keys are forwarded to the viewport's default scroll keymap (j/k, +// arrows, PgUp/PgDn, space, f/b, u/d). Extracted from handleKey to keep its +// cyclomatic complexity within the gocyclo budget. +func (m RestoreModel) handleDryRunKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.Code { + case 'q', 27: // esc + m.State = restoreStateList + return m, nil + case 'g': + m.viewport.GotoTop() + return m, nil + case 'G': + m.viewport.GotoBottom() + return m, nil + case '\r': + m.State = restoreStateConfirm + modal := components.NewModal("Confirm Restore", + fmt.Sprintf("Restore backup %s? This will overwrite current config.", m.SelectedID), + []string{"Confirm", "Cancel"}) + m.Modal = &modal + return m, nil + default: + // Forward scroll keys to the viewport, whose default keymap handles them. + newVp, _ := m.viewport.Update(msg) + m.viewport = newVp + return m, nil + } +} + func (m RestoreModel) dryRunCmd(backupID string) tea.Cmd { return func() tea.Msg { if m.runRestore == nil { @@ -214,7 +262,7 @@ func (m RestoreModel) dryRunCmd(backupID string) tea.Cmd { func (m RestoreModel) runRestoreCmd(backupID string) tea.Cmd { return func() tea.Msg { if m.runRestore == nil { - return restoreExecResultMsg{err: fmt.Errorf("restore not available")} + return restoreExecResultMsg{err: errors.New("restore not available")} } _, err := m.runRestore(backupID, false) return restoreExecResultMsg{err: err} @@ -223,6 +271,10 @@ func (m RestoreModel) runRestoreCmd(backupID string) tea.Cmd { // View renders the current restore state. func (m RestoreModel) View() tea.View { + if styles.IsTooSmall(m.Width, m.Height) { + return tea.NewView(styles.RenderTooSmall(m.Width, m.Height)) + } + var content string switch { @@ -261,7 +313,8 @@ func (m RestoreModel) renderEmptyState() string { var b strings.Builder b.WriteString(styles.ScreenTitleStyle.Render("Restore")) b.WriteString("\n\n") - b.WriteString("No backups found. Create one first.") + // Styled empty-state block (icon + message + hint, REQ-TP-007). + b.WriteString(components.RenderEmptyState("∅", "No backups found", "Run 'bak backup' to create one first")) b.WriteString("\n\n") b.WriteString("[q] back") return b.String() @@ -302,12 +355,42 @@ func (m RestoreModel) renderBackupList() string { return b.String() } +// dryRunHeaderLines is the number of rendered lines above the viewport in the +// dry-run screen (title, blank, "Backup: ", blank). +const dryRunHeaderLines = 4 + +// dryRunFooterLines is the number of rendered lines below the viewport +// (blank, confirm/cancel help bar). +const dryRunFooterLines = 2 + +// dryRunViewportHeight returns the viewport height for the dry-run screen, +// reserving space for the header and footer and clamping to a minimum of 1 so +// the viewport always has a non-zero visible region. +func dryRunViewportHeight(termHeight int) int { + h := termHeight - dryRunHeaderLines - dryRunFooterLines + if h < 1 { + return 1 + } + return h +} + func (m RestoreModel) renderDryRun() string { var b strings.Builder b.WriteString(styles.ScreenTitleStyle.Render("Restore — Dry Run Preview")) b.WriteString("\n\n") fmt.Fprintf(&b, "Backup: %s\n\n", m.SelectedID) - b.WriteString(m.DryRunOutput) + + // Render the diff inside the bounded viewport (REQ-TP-005). When the + // viewport was never sized (e.g. a test that sets DryRunOutput directly), + // size it on a copy so the content is still visible rather than blank. + vp := m.viewport + if !m.vpReady { + vp.SetWidth(m.Width) + vp.SetHeight(dryRunViewportHeight(m.Height)) + vp.SetContent(m.DryRunOutput) + } + b.WriteString(vp.View()) + b.WriteString("\n\n") b.WriteString("[enter] Confirm restore [q] Cancel") return b.String() diff --git a/internal/tui/screens/restore_test.go b/internal/tui/screens/restore_test.go index 18332be..9d03917 100644 --- a/internal/tui/screens/restore_test.go +++ b/internal/tui/screens/restore_test.go @@ -5,6 +5,7 @@ package screens import ( "errors" + "fmt" "strings" "testing" @@ -103,6 +104,31 @@ func TestRestore_EmptyState(t *testing.T) { //nolint:paralleltest // not yet par } } +// TestRestore_EmptyState_Styled verifies the empty restore list renders the +// shared styled empty-state block (icon + message + hint) via +// components.RenderEmptyState, not a bare string (tui-personality REQ-TP-007). +func TestRestore_EmptyState_Styled(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending + deps := restoreTestDeps{ + listBackupsFn: func() ([]BackupInfo, error) { + return nil, nil // no backups + }, + } + + m := NewRestoreModel(deps.listBackupsFn, deps.runRestoreFn) + m.Width = 80 + m.Height = 24 + m.State = restoreStateList + + output := m.View().Content + + if !strings.Contains(output, "No backups found") { + t.Errorf("styled empty state missing message 'No backups found': %q", output) + } + if !strings.Contains(output, "bak backup") { + t.Errorf("styled empty state missing hint 'bak backup': %q", output) + } +} + // ============================================================================= // TestRestore_StateTransition_ListToDryRun — RED // ============================================================================= @@ -332,12 +358,63 @@ func makeTestModal() *components.ModalModel { return &modal } +// TestRestore_ConfirmModal_KeyForwarding verifies that keypresses in the +// confirm state reach the modal so the user can confirm/cancel via the +// keyboard (Enter confirms, Escape cancels). Without forwarding the modal is +// rendered but non-interactive. +func TestRestore_ConfirmModal_KeyForwarding(t *testing.T) { //nolint:paralleltest // matches established codebase convention across all tui tests + makeConfirmModel := func() RestoreModel { + m := NewRestoreModel(nil, nil) + m.Width = 80 + m.Height = 24 + m.State = restoreStateConfirm + m.SelectedID = "backup-1" + modal := components.NewModal("Confirm Restore", "msg", []string{"Confirm", "Cancel"}) + m.Modal = &modal + return m + } + + t.Run("enter confirms", func(t *testing.T) { //nolint:paralleltest // subtests share table/struct state + m := makeConfirmModel() + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("enter in confirm modal returned nil cmd, want ModalResultMsg") + } + msg := cmd() + result, ok := msg.(components.ModalResultMsg) + if !ok { + t.Fatalf("enter cmd returned %T, want ModalResultMsg", msg) + } + if !result.Confirmed { + t.Error("enter on first button should confirm, got Confirmed=false") + } + }) + + t.Run("escape cancels", func(t *testing.T) { //nolint:paralleltest // subtests share table/struct state + m := makeConfirmModel() + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if cmd == nil { + t.Fatal("escape in confirm modal returned nil cmd, want ModalResultMsg") + } + msg := cmd() + result, ok := msg.(components.ModalResultMsg) + if !ok { + t.Fatalf("escape cmd returned %T, want ModalResultMsg", msg) + } + if result.Confirmed { + t.Error("escape should cancel, got Confirmed=true") + } + }) +} + // ============================================================================= // Phase 3: Render helper coverage for restore.go (0% before backfill) // ============================================================================= func TestRestore_RenderErrorState(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending m := NewRestoreModel(nil, nil) + m.Width = 80 + m.Height = 24 m.Err = errors.New("connection refused") output := m.View().Content @@ -354,6 +431,22 @@ func TestRestore_RenderErrorState(t *testing.T) { //nolint:paralleltest // not y } } +// TestRestore_View_TooSmall verifies the dry-run screen guards against +// terminals below the minimum dimensions (AGENTS.md TUI Responsiveness). +func TestRestore_View_TooSmall(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending + m := NewRestoreModel(nil, nil) + m.Width = 20 + m.Height = 10 + m.State = restoreStateList + m.Backups = []BackupInfo{{ID: "x"}} + + output := m.View().Content + + if !strings.Contains(output, "Terminal too small") { + t.Errorf("too-small guard missing message: %q", output) + } +} + func TestRestore_RenderBackupList(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending m := NewRestoreModel(nil, nil) m.Width = 80 @@ -393,6 +486,42 @@ func TestRestore_RenderBackupList_Empty(t *testing.T) { //nolint:paralleltest // } } +// TestRestore_ListNavigation_CursorBounds verifies that list navigation keeps +// the cursor in a valid range when it starts negative or out-of-bounds +// (AGENTS.md TUI Testing: MUST test negative/out-of-bounds cursor edge cases). +func TestRestore_ListNavigation_CursorBounds(t *testing.T) { //nolint:paralleltest // matches established codebase convention across all tui tests + backups := []BackupInfo{{ID: "1"}, {ID: "2"}, {ID: "3"}} + + tests := []struct { + name string + startCur int + key tea.KeyPressMsg + }{ + {"negative cursor + j", -1, tea.KeyPressMsg{Code: 'j'}}, + {"out-of-bounds cursor + j", 99, tea.KeyPressMsg{Code: 'j'}}, + {"negative cursor + k", -1, tea.KeyPressMsg{Code: 'k'}}, + {"out-of-bounds cursor + k", 99, tea.KeyPressMsg{Code: 'k'}}, + } + + for _, tt := range tests { //nolint:paralleltest // subtests share table/struct state + t.Run(tt.name, func(t *testing.T) { + m := NewRestoreModel(nil, nil) + m.Width = 80 + m.Height = 24 + m.State = restoreStateList + m.Backups = backups + m.Cursor = tt.startCur + + nm, _ := m.Update(tt.key) + r := nm.(RestoreModel) + + if r.Cursor < 0 || r.Cursor >= len(backups) { + t.Errorf("%s: cursor = %d, want in [0, %d)", tt.name, r.Cursor, len(backups)) + } + }) + } +} + func TestRestore_RenderRunning(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending m := NewRestoreModel(nil, nil) m.Width = 80 @@ -445,3 +574,138 @@ func TestRestore_RenderDone_Error(t *testing.T) { //nolint:paralleltest // not y t.Errorf("renderDone error missing 'Error': %q", output) } } + +// ============================================================================= +// Phase 3: Viewport Dry-Run (PR 2 — Tier 2a) — RED +// ============================================================================= + +// buildLongDiff returns an n-line diff string where each line is unique and +// numbered, so tests can assert which lines are visible inside the viewport. +func buildLongDiff(n int) string { + var b strings.Builder + for i := 1; i <= n; i++ { + fmt.Fprintf(&b, "diff line %d\n", i) + } + return b.String() +} + +// TestRestore_DryRun_ViewportRendersBoundedContent verifies that the dry-run +// diff renders inside a bubbles/viewport (REQ-TP-005): the first diff line is +// visible (content was set + rendered by viewport.View()) while the last line +// is NOT (the viewport bounds content to its height instead of dumping the raw +// string). On the current raw-dump implementation this fails because line 80 +// leaks into the output. +func TestRestore_DryRun_ViewportRendersBoundedContent(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending + m := NewRestoreModel(nil, nil) + m.SelectedID = "backup-1" + // Size the embedded viewport via WindowSizeMsg. + nm, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = nm.(RestoreModel) + // Deliver the dry-run result → viewport.SetContent + state transition. + nm, _ = m.Update(restoreDryRunResultMsg{output: buildLongDiff(80)}) + m = nm.(RestoreModel) + + if m.State != restoreStateDryRun { + t.Fatalf("state = %v, want restoreStateDryRun", m.State) + } + + out := m.View().Content + + // Heading + selected id must still be rendered. + if !strings.Contains(out, "Dry Run") { + t.Errorf("dry-run view missing 'Dry Run' heading: %q", out) + } + if !strings.Contains(out, "backup-1") { + t.Errorf("dry-run view missing selected id: %q", out) + } + // The viewport must render the first diff line (content was set). + if !strings.Contains(out, "diff line 1") { + t.Errorf("viewport did not render first diff line: %q", out) + } + // The viewport bounds content to its height: the last line must NOT leak + // (a raw dump would include it). + if strings.Contains(out, "diff line 80") { + t.Errorf("viewport leaked last diff line — content not bounded (raw dump, not viewport): %q", out) + } +} + +// sizedDryRunModel returns a RestoreModel sized to w x h with a multi-line diff +// loaded into the viewport and the state set to restoreStateDryRun. +func sizedDryRunModel(w, h, diffLines int) RestoreModel { + m := NewRestoreModel(nil, nil) + m.SelectedID = "backup-1" + nm, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: h}) + m = nm.(RestoreModel) + nm, _ = m.Update(restoreDryRunResultMsg{output: buildLongDiff(diffLines)}) + m = nm.(RestoreModel) + return m +} + +// TestRestore_DryRun_ScrollKeys verifies the viewport scroll bindings in +// restoreStateDryRun (REQ-TP-005 / tui-interactive-preview). j/k, arrows, and +// PgUp/PgDn forward to the viewport's default keymap; g/G jump to top/bottom. +// Table-driven so each binding is exercised with its own key + expectation. +func TestRestore_DryRun_ScrollKeys(t *testing.T) { //nolint:paralleltest // matches established codebase convention across all tui tests + tests := []struct { + name string + key tea.KeyPressMsg + preScroll bool // scroll down with PgDn before pressing key (creates room to scroll up) + wantZero bool // expect YOffset == 0 after the key (goto top) + wantDown bool // expect YOffset to increase after the key (scroll/bottom) + }{ + {"j scrolls down one line", tea.KeyPressMsg{Code: 'j'}, false, false, true}, + {"arrow down scrolls down", tea.KeyPressMsg{Code: tea.KeyDown}, false, false, true}, + {"PgDn scrolls down a page", tea.KeyPressMsg{Code: tea.KeyPgDown}, false, false, true}, + {"G jumps to bottom", tea.KeyPressMsg{Code: 'G'}, false, false, true}, + {"k scrolls up one line", tea.KeyPressMsg{Code: 'k'}, true, false, false}, + {"PgUp scrolls up a page", tea.KeyPressMsg{Code: tea.KeyPgUp}, true, false, false}, + {"g jumps to top", tea.KeyPressMsg{Code: 'g'}, true, true, false}, + } + + for _, tt := range tests { //nolint:paralleltest // subtests share viewport/struct state + t.Run(tt.name, func(t *testing.T) { + m := sizedDryRunModel(80, 24, 60) + + if tt.preScroll { + nm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + m = nm.(RestoreModel) + } + before := m.viewport.YOffset() + if tt.preScroll && before == 0 { + t.Fatalf("precondition: pre-scroll did not move YOffset, cannot assert %s", tt.name) + } + + nm, _ := m.Update(tt.key) + m = nm.(RestoreModel) + after := m.viewport.YOffset() + + switch { + case tt.wantZero: + if after != 0 { + t.Errorf("%s: YOffset = %d, want 0 (top)", tt.name, after) + } + case tt.wantDown: + if after <= before { + t.Errorf("%s: YOffset did not increase: %d -> %d", tt.name, before, after) + } + default: // up-direction: expect YOffset to decrease + if after >= before { + t.Errorf("%s: YOffset did not decrease: %d -> %d", tt.name, before, after) + } + } + }) + } +} + +// TestRestore_DryRun_QReturnsToList verifies 'q' returns to the backup list +// (REQ-TP-005 scenario "q returns to backup list"). +func TestRestore_DryRun_QReturnsToList(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending + m := sizedDryRunModel(80, 24, 30) + + nm, _ := m.Update(tea.KeyPressMsg{Code: 'q'}) + m = nm.(RestoreModel) + + if m.State != restoreStateList { + t.Errorf("after 'q' in dry-run: state = %v, want restoreStateList", m.State) + } +} diff --git a/internal/tui/styles/screens.go b/internal/tui/styles/screens.go index 68f4b19..c16be55 100644 --- a/internal/tui/styles/screens.go +++ b/internal/tui/styles/screens.go @@ -110,3 +110,25 @@ var ( // equals its text width and truncation math stays predictable (AGENTS.md // §styles: package-level var, no inline NewStyle in render paths). var StatusBarStyle = lipgloss.NewStyle().Foreground(ColorSubtle) + +// Empty-state styles (tui-personality REQ-TP-007): a Love-colored icon, an +// italic message, and a muted hint with the next action. Package-level vars +// per AGENTS.md §styles (no inline NewStyle in render paths). +var ( + // EmptyStateIconStyle renders the empty-state icon (Rose Pine Love). + EmptyStateIconStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(ColorLove). + Padding(1, 2) + + // EmptyStateMsgStyle renders the empty-state message (italic primary text). + EmptyStateMsgStyle = lipgloss.NewStyle(). + Italic(true). + Foreground(ColorText). + Padding(0, 2) + + // EmptyStateHintStyle renders the empty-state hint (de-emphasized next action). + EmptyStateHintStyle = lipgloss.NewStyle(). + Foreground(ColorMuted). + Padding(0, 2) +) diff --git a/openspec/changes/tui-personality/tasks.md b/openspec/changes/tui-personality/tasks.md index 7331e83..b56c116 100644 --- a/openspec/changes/tui-personality/tasks.md +++ b/openspec/changes/tui-personality/tasks.md @@ -44,19 +44,19 @@ Chain strategy: stacked-to-main ## Phase 3: Viewport Dry-Run (PR 2 — Tier 2a) -- [ ] 3.1 [RED] `internal/tui/screens/restore_test.go`: send `restoreDryRunResultMsg{output: "diff..."}`, assert `viewport.SetContent` called and `View()` renders viewport output -- [ ] 3.2 [GREEN] `internal/tui/screens/restore.go`: add `viewport viewport.Model` + `vpReady bool` fields; `WindowSizeMsg` sets viewport dimensions; `restoreDryRunResultMsg` calls `SetContent`; `renderDryRun` writes `m.viewport.View()` -- [ ] 3.3 [RED] `restore_test.go`: press `PgDn`/`PgUp`/`j`/`k`/`g`/`G` in `restoreStateDryRun`, assert viewport scroll position changes; press `q`, assert transition to `restoreStateList` -- [ ] 3.4 [GREEN] `restore.go` Update: forward scroll keys (`j/k/↑/↓/PgUp/PgDn/g/G`) to `m.viewport.Update`; `q` transitions to list state +- [x] 3.1 [RED] `internal/tui/screens/restore_test.go`: send `restoreDryRunResultMsg{output: "diff..."}`, assert `viewport.SetContent` called and `View()` renders viewport output +- [x] 3.2 [GREEN] `internal/tui/screens/restore.go`: add `viewport viewport.Model` + `vpReady bool` fields; `WindowSizeMsg` sets viewport dimensions; `restoreDryRunResultMsg` calls `SetContent`; `renderDryRun` writes `m.viewport.View()` +- [x] 3.3 [RED] `restore_test.go`: press `PgDn`/`PgUp`/`j`/`k`/`g`/`G` in `restoreStateDryRun`, assert viewport scroll position changes; press `q`, assert transition to `restoreStateList` +- [x] 3.4 [GREEN] `restore.go` Update: forward scroll keys (`j/k/↑/↓/PgUp/PgDn/g/G`) to `m.viewport.Update`; `q` transitions to list state ## Phase 4: Mouse Navigation & Empty States (PR 2 — Tier 2b) -- [ ] 4.1 [RED] `internal/tui/screens/dashboard_test.go`: `MouseWheelMsg{Button: MouseWheelDown}` advances table cursor; `MouseClickMsg{Y: 2}` sets cursor; mouse suppressed when `search.IsActive()` -- [ ] 4.2 [GREEN] `internal/tui/screens/dashboard.go`: set `v.MouseMode = tea.MouseModeCellMotion` in `View()`; add `MouseWheelMsg`/`MouseClickMsg` cases in `Update`; guard `m.search.IsActive()` return early -- [ ] 4.3 [RED] `internal/tui/components/empty_state_test.go`: table-driven — output contains icon, italic message, hint text -- [ ] 4.4 [GREEN] `internal/tui/components/empty_state.go`: stateless `RenderEmptyState(icon, message, hint string) string` -- [ ] 4.5 [GREEN] `internal/tui/styles/screens.go`: add `EmptyStateIconStyle`, `EmptyStateMsgStyle`, `EmptyStateHintStyle` package-level vars -- [ ] 4.6 [REFACTOR] `dashboard.go`, `restore.go`, `cloud.go`: replace bare empty strings with `components.RenderEmptyState(...)` calls +- [x] 4.1 [RED] `internal/tui/screens/dashboard_test.go`: `MouseWheelMsg{Button: MouseWheelDown}` advances table cursor; `MouseClickMsg{Y: 2}` sets cursor; mouse suppressed when `search.IsActive()` +- [x] 4.2 [GREEN] `internal/tui/screens/dashboard.go`: set `v.MouseMode = tea.MouseModeCellMotion` in `View()`; add `MouseWheelMsg`/`MouseClickMsg` cases in `Update`; guard `m.search.IsActive()` return early +- [x] 4.3 [RED] `internal/tui/components/empty_state_test.go`: table-driven — output contains icon, italic message, hint text +- [x] 4.4 [GREEN] `internal/tui/components/empty_state.go`: stateless `RenderEmptyState(icon, message, hint string) string` +- [x] 4.5 [GREEN] `internal/tui/styles/screens.go`: add `EmptyStateIconStyle`, `EmptyStateMsgStyle`, `EmptyStateHintStyle` package-level vars +- [x] 4.6 [REFACTOR] `dashboard.go`, `restore.go`, `cloud.go`: replace bare empty strings with `components.RenderEmptyState(...)` calls ## Phase 5: Paste Support (PR 3 — Tier 3)