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
8 changes: 4 additions & 4 deletions cmd/app/api_integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,7 @@ func stripDiffHeader(out string) string {
}

// syncSelectedApplications syncs the currently selected applications
func (m *Model) syncSelectedApplications(prune bool) tea.Cmd {
func (m *Model) syncSelectedApplications(prune, force bool) tea.Cmd {
if m.state.Server == nil {
return func() tea.Msg {
return model.ApiErrorMsg{Message: "No server configured"}
Expand All @@ -830,7 +830,7 @@ func (m *Model) syncSelectedApplications(prune bool) tea.Cmd {
for _, appName := range selectedApps {
ctx, cancel := appcontext.WithAPITimeout(context.Background())
// Multi-app sync doesn't track per-app namespaces; pass nil (uses Argo CD default)
err := apiService.SyncApplication(ctx, server, appName, nil, prune)
err := apiService.SyncApplication(ctx, server, appName, nil, api.SyncOptions{Prune: prune, Force: force})
cancel()
if err != nil {
// Convert to structured error and return via TUI error handling
Expand Down Expand Up @@ -918,7 +918,7 @@ func (m *Model) deleteApplication(req model.AppDeleteRequestMsg) tea.Cmd {
}

// syncSingleApplication syncs a specific application
func (m *Model) syncSingleApplication(appName string, appNamespace *string, prune bool) tea.Cmd {
func (m *Model) syncSingleApplication(appName string, appNamespace *string, prune, force bool) tea.Cmd {
if m.state.Server == nil {
return func() tea.Msg {
return model.ApiErrorMsg{Message: "No server configured"}
Expand All @@ -934,7 +934,7 @@ func (m *Model) syncSingleApplication(appName string, appNamespace *string, prun
apiService := services.NewArgoApiService(server)

cblog.With("component", "api").Info("Starting sync", "app", appName)
err := apiService.SyncApplication(ctx, server, appName, appNamespace, prune)
err := apiService.SyncApplication(ctx, server, appName, appNamespace, api.SyncOptions{Prune: prune, Force: force})
if err != nil {
cblog.With("component", "api").Error("Sync failed", "app", appName, "err", err)
// Convert to structured error and return via TUI error handling
Expand Down
80 changes: 70 additions & 10 deletions cmd/app/input_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -664,8 +664,22 @@ func (m *Model) diffPageSize() int {

// handleConfirmSyncKeys handles input when in sync confirmation mode
func (m *Model) handleConfirmSyncKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// The force confirmation is its own input state. The options are not on
// screen behind it, so keys that would change them must not: toggling
// force off there would run a plain sync from a dialog that said "Force
// sync".
if m.state.Modals.ConfirmSyncForcePending {
return m.handleForceSyncConfirmKeys(msg)
}

switch msg.String() {
case "esc", "q":
// Backing out of the force confirmation returns to the options
// rather than abandoning the sync the user was setting up.
if m.state.Modals.ConfirmSyncForcePending {
m.state.Modals.ConfirmSyncForcePending = false
return m, nil
}
m.state.Mode = model.ModeNormal
m.state.Modals.ConfirmTarget = nil
m.state.Modals.ConfirmTargetNamespace = nil
Expand All @@ -690,10 +704,64 @@ func (m *Model) handleConfirmSyncKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
fallthrough
case "y":
// Force deletes and recreates live resources, so it gets its own
// confirmation rather than riding along on this one.
if m.state.Modals.ConfirmSyncForce {
m.state.Modals.ConfirmSyncForcePending = true
return m, nil
}

return m.startConfirmedSync()
case "p":
// Toggle prune option
m.state.Modals.ConfirmSyncPrune = !m.state.Modals.ConfirmSyncPrune
return m, nil
case "f":
m.state.Modals.ConfirmSyncForce = !m.state.Modals.ConfirmSyncForce
return m, nil
case "w":
// Toggle watch option (single or multi)
m.state.Modals.ConfirmSyncWatch = !m.state.Modals.ConfirmSyncWatch
return m, nil
}
return m, nil
}

// handleForceSyncConfirmKeys handles the second step of a forced sync. Only
// the two buttons and the ways out are live here.
func (m *Model) handleForceSyncConfirmKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "q":
m.state.Modals.ConfirmSyncForcePending = false
return m, nil
case "left", "h":
m.state.Modals.ConfirmSyncSelected = 0
return m, nil
case "right", "l":
m.state.Modals.ConfirmSyncSelected = 1
return m, nil
case "enter":
if m.state.Modals.ConfirmSyncSelected == 1 {
// Back to the options, with everything the user chose intact.
m.state.Modals.ConfirmSyncForcePending = false
return m, nil
}
fallthrough
case "y":
m.state.Modals.ConfirmSyncForcePending = false
return m.startConfirmedSync()
}
return m, nil
}

// startConfirmedSync kicks off the sync the modal has been configuring.
func (m *Model) startConfirmedSync() (tea.Model, tea.Cmd) {
{
// Confirm sync - keep modal open and show loading overlay
target := m.state.Modals.ConfirmTarget
targetNamespace := m.state.Modals.ConfirmTargetNamespace
prune := m.state.Modals.ConfirmSyncPrune
force := m.state.Modals.ConfirmSyncForce
m.state.Modals.ConfirmSyncLoading = true
m.state.Mode = model.ModeConfirmSync

Expand All @@ -702,20 +770,12 @@ func (m *Model) handleConfirmSyncKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
"target", *target,
"isMulti", *target == "__MULTI__")
if *target == "__MULTI__" {
return m, m.syncSelectedApplications(prune)
return m, m.syncSelectedApplications(prune, force)
} else {
return m, m.syncSingleApplication(*target, targetNamespace, prune)
return m, m.syncSingleApplication(*target, targetNamespace, prune, force)
}
}
return m, nil
case "p":
// Toggle prune option
m.state.Modals.ConfirmSyncPrune = !m.state.Modals.ConfirmSyncPrune
return m, nil
case "w":
// Toggle watch option (single or multi)
m.state.Modals.ConfirmSyncWatch = !m.state.Modals.ConfirmSyncWatch
return m, nil
}
return m, nil
}
Expand Down
161 changes: 161 additions & 0 deletions cmd/app/sync_force_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package main

import (
"testing"

tea "charm.land/bubbletea/v2"
"github.com/darksworm/argonaut/pkg/model"
)

func syncModalModel(t *testing.T) *Model {
t.Helper()
m := buildBaseModel(100, 30)
m.state.Mode = model.ModeConfirmSync
target := "demo-app"
m.state.Modals.ConfirmTarget = &target
return m
}

func press(t *testing.T, m *Model, key string) *Model {
t.Helper()
msg := tea.KeyPressMsg{Code: rune(key[0]), Text: key}
if key == "esc" {
msg = tea.KeyPressMsg{Code: tea.KeyEscape}
}
updated, _ := m.handleConfirmSyncKeys(msg)
next, ok := updated.(*Model)
if !ok {
t.Fatalf("expected the handler to return a *Model, got %T", updated)
}
return next
}

func TestSyncModal_FKeyTogglesForce(t *testing.T) {
m := syncModalModel(t)

m = press(t, m, "f")
if !m.state.Modals.ConfirmSyncForce {
t.Error("expected f to turn force on")
}

m = press(t, m, "f")
if m.state.Modals.ConfirmSyncForce {
t.Error("expected f to turn force off again")
}
}

func TestSyncModal_ConfirmingWithForceAsksAgainBeforeSyncing(t *testing.T) {
m := syncModalModel(t)
m.state.Modals.ConfirmSyncForce = true

m = press(t, m, "y")

if !m.state.Modals.ConfirmSyncForcePending {
t.Error("expected a force sync to ask for confirmation first")
}
if m.state.Modals.ConfirmSyncLoading {
t.Error("expected the sync not to start until the force confirmation is answered")
}
}

func TestSyncModal_ConfirmingWithoutForceSyncsStraightAway(t *testing.T) {
m := syncModalModel(t)

m = press(t, m, "y")

if m.state.Modals.ConfirmSyncForcePending {
t.Error("expected no force confirmation when force is off")
}
if !m.state.Modals.ConfirmSyncLoading {
t.Error("expected the sync to start immediately")
}
}

func TestSyncModal_AnsweringTheForceConfirmationStartsTheSync(t *testing.T) {
m := syncModalModel(t)
m.state.Modals.ConfirmSyncForce = true
m.state.Modals.ConfirmSyncForcePending = true

m = press(t, m, "y")

if m.state.Modals.ConfirmSyncForcePending {
t.Error("expected the force confirmation to close once answered")
}
if !m.state.Modals.ConfirmSyncLoading {
t.Error("expected the sync to start after confirming the force")
}
}

func TestSyncModal_CancellingTheForceConfirmationKeepsTheOptions(t *testing.T) {
m := syncModalModel(t)
m.state.Modals.ConfirmSyncPrune = true
m.state.Modals.ConfirmSyncForce = true
m.state.Modals.ConfirmSyncForcePending = true

m = press(t, m, "esc")

if m.state.Modals.ConfirmSyncForcePending {
t.Error("expected esc to leave the force confirmation")
}
if m.state.Mode != model.ModeConfirmSync {
t.Errorf("expected to return to the sync modal, got mode %q", m.state.Mode)
}
if !m.state.Modals.ConfirmSyncForce || !m.state.Modals.ConfirmSyncPrune {
t.Error("expected the chosen options to survive cancelling the force confirmation")
}
}

func TestSyncModal_CancelButtonOnTheForceConfirmationKeepsTheOptions(t *testing.T) {
m := syncModalModel(t)
m.state.Modals.ConfirmSyncForce = true
m.state.Modals.ConfirmSyncForcePending = true
m.state.Modals.ConfirmSyncSelected = 1 // Cancel

updated, _ := m.handleConfirmSyncKeys(tea.KeyPressMsg{Code: tea.KeyEnter})
m = updated.(*Model)

if m.state.Mode != model.ModeConfirmSync {
t.Errorf("expected cancelling the force confirmation to return to the options, got mode %q", m.state.Mode)
}
if m.state.Modals.ConfirmSyncForcePending {
t.Error("expected the force confirmation to close")
}
if !m.state.Modals.ConfirmSyncForce {
t.Error("expected force to survive cancelling its confirmation")
}
}

func TestSyncModal_OptionKeysDoNothingWhileTheForceConfirmationIsUp(t *testing.T) {
for _, key := range []string{"p", "f", "w", "d"} {
t.Run(key, func(t *testing.T) {
m := syncModalModel(t)
m.state.Modals.ConfirmSyncPrune = true
m.state.Modals.ConfirmSyncForce = true
m.state.Modals.ConfirmSyncWatch = true
m.state.Modals.ConfirmSyncForcePending = true
before := m.state.Modals

m = press(t, m, key)

if m.state.Modals.ConfirmSyncPrune != before.ConfirmSyncPrune ||
m.state.Modals.ConfirmSyncForce != before.ConfirmSyncForce ||
m.state.Modals.ConfirmSyncWatch != before.ConfirmSyncWatch {
t.Errorf("expected %q to be ignored while the force confirmation is up", key)
}
})
}
}

func TestSyncModal_ConfirmingTheForceDialogAlwaysForces(t *testing.T) {
m := syncModalModel(t)
m.state.Modals.ConfirmSyncForce = true
m.state.Modals.ConfirmSyncForcePending = true

// A stray f must not disarm the force the dialog is asking about.
m = press(t, m, "f")
m = press(t, m, "y")

if !m.state.Modals.ConfirmSyncForce {
t.Error("expected the sync to force after confirming a dialog that said Force sync")
}
}
1 change: 1 addition & 0 deletions cmd/app/testdata/snapshots/modal_confirm_sync.golden
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
│ Sync demo-app? │
│ │
│ p Prune Off │
│ f Force Off │
│ w Watch On │
│ │
│ Sync Cancel │
Expand Down
10 changes: 10 additions & 0 deletions cmd/app/testdata/snapshots/modal_confirm_sync_force.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@

╭────────────────────────────────────────────────╮
│ │
│ Force sync deletes and recreates resources │
│ in demo-app. │
│ │
│ Force sync Cancel │
│ │
╰────────────────────────────────────────────────╯

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
│ Sync demo-app? │
│ │
│ p Prune On removes extras │
│ f Force Off │
│ w Watch On │
│ │
│ Sync Cancel │
Expand Down
5 changes: 5 additions & 0 deletions cmd/app/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,10 @@ func (m *Model) renderConfirmSyncModal() string {
target := *m.state.Modals.ConfirmTarget
isMulti := target == "__MULTI__"

if m.state.Modals.ConfirmSyncForcePending {
return m.renderForceSyncConfirm(target, isMulti)
}

// Modal width: compact and centered
half := m.state.Terminal.Cols / 2
modalWidth := min(max(36, half), m.state.Terminal.Cols-6)
Expand Down Expand Up @@ -774,6 +778,7 @@ func (m *Model) renderConfirmSyncModal() string {
// buttons stay centered, because they are not a list.
aux := renderSyncOptions([]syncOption{
{Key: "p", Label: "Prune", On: m.state.Modals.ConfirmSyncPrune, Clause: "removes extras", Danger: true},
{Key: "f", Label: "Force", On: m.state.Modals.ConfirmSyncForce, Clause: "delete & recreate", Danger: true},
{Key: "w", Label: "Watch", On: m.state.Modals.ConfirmSyncWatch},
}, innerWidth)

Expand Down
17 changes: 17 additions & 0 deletions cmd/app/view_modals.go
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,23 @@ func (m *Model) renderTwoButtonConfirm(title string, accent color.Color, confirm
return outer.Render(wrapper.Render(body))
}

// renderForceSyncConfirm is the second step of a forced sync. Force bypasses
// graceful deletion, so it is confirmed on its own rather than riding along
// on the sync confirmation.
func (m *Model) renderForceSyncConfirm(target string, isMulti bool) string {
subject := target
if isMulti {
subject = fmt.Sprintf("%d applications", len(m.state.Selections.SelectedApps))
}

bright := lipgloss.NewStyle().Foreground(whiteBright)
title := bright.Render("Force sync deletes and recreates resources in ") +
bright.Bold(true).Render(subject) + bright.Render(".")

return m.renderTwoButtonConfirm(title, outOfSyncColor, "Force sync",
m.state.Modals.ConfirmSyncSelected, "")
}

// renderTerminateConfirmModal asks whether to cancel the app's running operation
func (m *Model) renderTerminateConfirmModal() string {
st := m.state.Modals.Terminate
Expand Down
Loading
Loading