From e3ff780f874f295e90c47e5620b3a48fb9fd8291 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 04:45:12 +0000 Subject: [PATCH 1/8] refactor: display selectors as modals instead of full-screen views - ProfileSelector, RegionSelector, HelpView now use Modal overlay - q/Esc closes modal (no longer quits app) - Background remains visible (dimmed) for context - Closes #62 --- internal/app/app.go | 34 +++++----- internal/app/app_test.go | 138 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 18 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 4b3a7932..f706b03b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -222,13 +222,9 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Quit case key.Matches(msg, a.keys.Help): - // Show full help view helpView := view.NewHelpView() - if a.currentView != nil { - a.viewStack = append(a.viewStack, a.currentView) - } - a.currentView = helpView - return a, a.currentView.SetSize(a.width, a.height-2) + a.modal = &view.Modal{Content: helpView, Width: 70} + return a, a.modal.SetSize(a.width, a.height) case key.Matches(msg, a.keys.Command): a.commandMode = true @@ -244,24 +240,18 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, a.keys.Region): regionSelector := view.NewRegionSelector(a.ctx) - if a.currentView != nil { - a.viewStack = append(a.viewStack, a.currentView) - } - a.currentView = regionSelector + a.modal = &view.Modal{Content: regionSelector, Width: 45} return a, tea.Batch( - a.currentView.Init(), - a.currentView.SetSize(a.width, a.height-2), + regionSelector.Init(), + a.modal.SetSize(a.width, a.height), ) case key.Matches(msg, a.keys.Profile): profileSelector := view.NewProfileSelector() - if a.currentView != nil { - a.viewStack = append(a.viewStack, a.currentView) - } - a.currentView = profileSelector + a.modal = &view.Modal{Content: profileSelector, Width: 55} return a, tea.Batch( - a.currentView.Init(), - a.currentView.SetSize(a.width, a.height-2), + profileSelector.Init(), + a.modal.SetSize(a.width, a.height), ) } @@ -541,6 +531,14 @@ func (a *App) handleModalUpdate(msg tea.Msg) (tea.Model, tea.Cmd) { a.currentView.SetSize(a.width, a.height-2), ) + case navmsg.RegionChangedMsg: + a.modal = nil + return a.Update(msg) + + case navmsg.ProfilesChangedMsg: + a.modal = nil + return a.Update(msg) + case tea.KeyPressMsg: if view.IsEscKey(msg) || msg.Code == tea.KeyBackspace || msg.String() == "q" { if ic, ok := a.modal.Content.(view.InputCapture); ok && ic.HasActiveInput() { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 90ca4697..63acb45c 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -465,6 +465,144 @@ func TestModalNavigateClosesModal(t *testing.T) { } } +func TestRegionSelectorAsModal(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + app := New(ctx, reg) + app.width = 100 + app.height = 50 + + dashboard := &MockView{name: "Dashboard"} + app.currentView = dashboard + app.viewStack = nil + + keyMsg := tea.KeyPressMsg{Code: 0, Text: "R"} + app.Update(keyMsg) + + if app.modal == nil { + t.Error("Expected modal to be set after R key") + } + if app.currentView.StatusLine() != "Dashboard" { + t.Errorf("Expected currentView to remain Dashboard, got %s", app.currentView.StatusLine()) + } + if len(app.viewStack) != 0 { + t.Errorf("Expected viewStack to remain empty, got %d", len(app.viewStack)) + } +} + +func TestProfileSelectorAsModal(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + app := New(ctx, reg) + app.width = 100 + app.height = 50 + + dashboard := &MockView{name: "Dashboard"} + app.currentView = dashboard + app.viewStack = nil + + keyMsg := tea.KeyPressMsg{Code: 0, Text: "P"} + app.Update(keyMsg) + + if app.modal == nil { + t.Error("Expected modal to be set after P key") + } + if app.currentView.StatusLine() != "Dashboard" { + t.Errorf("Expected currentView to remain Dashboard, got %s", app.currentView.StatusLine()) + } +} + +func TestHelpViewAsModal(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + app := New(ctx, reg) + app.width = 100 + app.height = 50 + + dashboard := &MockView{name: "Dashboard"} + app.currentView = dashboard + app.viewStack = nil + + keyMsg := tea.KeyPressMsg{Code: 0, Text: "?"} + app.Update(keyMsg) + + if app.modal == nil { + t.Error("Expected modal to be set after ? key") + } + if app.currentView.StatusLine() != "Dashboard" { + t.Errorf("Expected currentView to remain Dashboard, got %s", app.currentView.StatusLine()) + } +} + +func TestModalClosesWithQ(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + app := New(ctx, reg) + app.width = 100 + app.height = 50 + + dashboard := &MockView{name: "Dashboard"} + app.currentView = dashboard + modalContent := &MockView{name: "RegionSelector"} + app.modal = &view.Modal{Content: modalContent} + + qKeyMsg := tea.KeyPressMsg{Code: 0, Text: "q"} + app.Update(qKeyMsg) + + if app.modal != nil { + t.Error("Expected modal to be nil after q key") + } + if app.currentView.StatusLine() != "Dashboard" { + t.Errorf("Expected currentView to remain Dashboard, got %s", app.currentView.StatusLine()) + } +} + +func TestRegionChangedMsgClosesModal(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + app := New(ctx, reg) + app.width = 100 + app.height = 50 + + dashboard := &MockView{name: "Dashboard"} + app.currentView = dashboard + modalContent := &MockView{name: "RegionSelector"} + app.modal = &view.Modal{Content: modalContent} + + msg := navmsg.RegionChangedMsg{Regions: []string{"us-west-2"}} + app.Update(msg) + + if app.modal != nil { + t.Error("Expected modal to be closed after RegionChangedMsg") + } +} + +func TestProfilesChangedMsgClosesModal(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + app := New(ctx, reg) + app.width = 100 + app.height = 50 + + dashboard := &MockView{name: "Dashboard"} + app.currentView = dashboard + modalContent := &MockView{name: "ProfileSelector"} + app.modal = &view.Modal{Content: modalContent} + + msg := navmsg.ProfilesChangedMsg{Selections: nil} + app.Update(msg) + + if app.modal != nil { + t.Error("Expected modal to be closed after ProfilesChangedMsg") + } +} + func TestWarningScreenDismissal(t *testing.T) { tests := []struct { name string From 68af34338147263a4dd393ebfaa19656fb9f6c0c Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 05:11:03 +0000 Subject: [PATCH 2/8] refactor: consolidate modal tests, add width constants - Add newTestApp() helper to reduce test boilerplate - Consolidate modal open/close tests into table-driven patterns - Extract modal width magic numbers into named constants --- internal/app/app.go | 25 ++-- internal/app/app_test.go | 262 ++++++++++++--------------------------- 2 files changed, 91 insertions(+), 196 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index f706b03b..a6ca7a5f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,7 +19,12 @@ import ( "github.com/clawscli/claws/internal/view" ) -// clearErrorMsg is sent to clear transient errors after a timeout +const ( + modalWidthHelp = 70 + modalWidthRegion = 45 + modalWidthProfile = 55 +) + type clearErrorMsg struct{} // awsContextReadyMsg is sent when AWS context initialization completes @@ -223,24 +228,12 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, a.keys.Help): helpView := view.NewHelpView() - a.modal = &view.Modal{Content: helpView, Width: 70} + a.modal = &view.Modal{Content: helpView, Width: modalWidthHelp} return a, a.modal.SetSize(a.width, a.height) - case key.Matches(msg, a.keys.Command): - a.commandMode = true - // Set completion providers if current view is a ResourceBrowser - if rb, ok := a.currentView.(*view.ResourceBrowser); ok { - a.commandInput.SetTagProvider(rb) - a.commandInput.SetDiffProvider(rb) - } else { - a.commandInput.SetTagProvider(nil) - a.commandInput.SetDiffProvider(nil) - } - return a, a.commandInput.Activate() - case key.Matches(msg, a.keys.Region): regionSelector := view.NewRegionSelector(a.ctx) - a.modal = &view.Modal{Content: regionSelector, Width: 45} + a.modal = &view.Modal{Content: regionSelector, Width: modalWidthRegion} return a, tea.Batch( regionSelector.Init(), a.modal.SetSize(a.width, a.height), @@ -248,7 +241,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, a.keys.Profile): profileSelector := view.NewProfileSelector() - a.modal = &view.Modal{Content: profileSelector, Width: 55} + a.modal = &view.Modal{Content: profileSelector, Width: modalWidthProfile} return a, tea.Batch( profileSelector.Init(), a.modal.SetSize(a.width, a.height), diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 63acb45c..e4c16417 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -33,13 +33,18 @@ func (m *MockView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -func TestEscInDetailView(t *testing.T) { +func newTestApp(t *testing.T) *App { + t.Helper() ctx := context.Background() reg := registry.New() - app := New(ctx, reg) app.width = 100 app.height = 50 + return app +} + +func TestEscInDetailView(t *testing.T) { + app := newTestApp(t) // Set up view stack: ServiceBrowser -> ResourceBrowser -> DetailView serviceBrowser := &MockView{name: "ServiceBrowser"} @@ -84,14 +89,8 @@ func TestEscInDetailView(t *testing.T) { } func TestEscInFilterMode(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) - app.width = 100 - app.height = 50 + app := newTestApp(t) - // Set up view with active filter resourceBrowser := &MockView{name: "ResourceBrowser", hasInput: true} serviceBrowser := &MockView{name: "ServiceBrowser"} @@ -130,12 +129,7 @@ func TestEscInFilterMode(t *testing.T) { } func TestNavigationFlow(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) - app.width = 100 - app.height = 50 + app := newTestApp(t) // Start with ServiceBrowser serviceBrowser := &MockView{name: "ServiceBrowser"} @@ -203,10 +197,7 @@ func TestNavigationFlow(t *testing.T) { } func TestAWSContextReadyMsg_Success(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) + app := newTestApp(t) app.awsInitializing = true // Simulate successful AWS init @@ -222,10 +213,7 @@ func TestAWSContextReadyMsg_Success(t *testing.T) { } func TestAWSContextReadyMsg_Timeout(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) + app := newTestApp(t) app.awsInitializing = true // Simulate timeout error @@ -241,10 +229,7 @@ func TestAWSContextReadyMsg_Timeout(t *testing.T) { } func TestAWSContextReadyMsg_IMDSError(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) + app := newTestApp(t) app.awsInitializing = true msg := awsContextReadyMsg{err: fmt.Errorf("operation error ec2imds: GetRegion, exceeded maximum number of attempts")} @@ -259,10 +244,7 @@ func TestAWSContextReadyMsg_IMDSError(t *testing.T) { } func TestProfileRefreshDoneMsg_Success(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) + app := newTestApp(t) app.profileRefreshID = 5 app.profileRefreshing = true @@ -283,10 +265,7 @@ func TestProfileRefreshDoneMsg_Success(t *testing.T) { } func TestProfileRefreshDoneMsg_StaleIgnored(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) + app := newTestApp(t) app.profileRefreshID = 10 app.profileRefreshing = true @@ -304,10 +283,7 @@ func TestProfileRefreshDoneMsg_StaleIgnored(t *testing.T) { } func TestProfileRefreshDoneMsg_Error(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) + app := newTestApp(t) app.profileRefreshID = 1 app.profileRefreshing = true @@ -329,10 +305,7 @@ func TestProfileRefreshDoneMsg_Error(t *testing.T) { } func TestProfileRefreshError_ClearedOnNewRefresh(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) + app := newTestApp(t) app.profileRefreshError = fmt.Errorf("previous error") app.currentView = &MockView{name: "Dashboard"} @@ -348,10 +321,7 @@ func TestProfileRefreshError_ClearedOnNewRefresh(t *testing.T) { } func TestProfileRefresh_RapidChangesOnlyLatestHonored(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) + app := newTestApp(t) app.currentView = &MockView{name: "Dashboard"} app.Update(navmsg.ProfilesChangedMsg{Selections: nil}) @@ -402,12 +372,7 @@ func TestProfileRefresh_RapidChangesOnlyLatestHonored(t *testing.T) { } func TestModalShowAndHide(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) - app.width = 100 - app.height = 50 + app := newTestApp(t) serviceBrowser := &MockView{name: "ServiceBrowser"} app.currentView = serviceBrowser @@ -436,12 +401,7 @@ func TestModalShowAndHide(t *testing.T) { } func TestModalNavigateClosesModal(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) - app.width = 100 - app.height = 50 + app := newTestApp(t) serviceBrowser := &MockView{name: "ServiceBrowser"} app.currentView = serviceBrowser @@ -465,141 +425,86 @@ func TestModalNavigateClosesModal(t *testing.T) { } } -func TestRegionSelectorAsModal(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) - app.width = 100 - app.height = 50 - - dashboard := &MockView{name: "Dashboard"} - app.currentView = dashboard - app.viewStack = nil - - keyMsg := tea.KeyPressMsg{Code: 0, Text: "R"} - app.Update(keyMsg) - - if app.modal == nil { - t.Error("Expected modal to be set after R key") - } - if app.currentView.StatusLine() != "Dashboard" { - t.Errorf("Expected currentView to remain Dashboard, got %s", app.currentView.StatusLine()) - } - if len(app.viewStack) != 0 { - t.Errorf("Expected viewStack to remain empty, got %d", len(app.viewStack)) +func TestKeyOpensModal(t *testing.T) { + tests := []struct { + name string + key string + }{ + {"region selector", "R"}, + {"profile selector", "P"}, + {"help view", "?"}, } -} - -func TestProfileSelectorAsModal(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) - app.width = 100 - app.height = 50 - dashboard := &MockView{name: "Dashboard"} - app.currentView = dashboard - app.viewStack = nil + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := newTestApp(t) + app.currentView = &MockView{name: "Dashboard"} + app.viewStack = nil - keyMsg := tea.KeyPressMsg{Code: 0, Text: "P"} - app.Update(keyMsg) + app.Update(tea.KeyPressMsg{Code: 0, Text: tt.key}) - if app.modal == nil { - t.Error("Expected modal to be set after P key") - } - if app.currentView.StatusLine() != "Dashboard" { - t.Errorf("Expected currentView to remain Dashboard, got %s", app.currentView.StatusLine()) + if app.modal == nil { + t.Errorf("Expected modal after %s key", tt.key) + } + if app.currentView.StatusLine() != "Dashboard" { + t.Errorf("Expected currentView Dashboard, got %s", app.currentView.StatusLine()) + } + if len(app.viewStack) != 0 { + t.Errorf("Expected empty viewStack, got %d", len(app.viewStack)) + } + }) } } -func TestHelpViewAsModal(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) - app.width = 100 - app.height = 50 - - dashboard := &MockView{name: "Dashboard"} - app.currentView = dashboard - app.viewStack = nil - - keyMsg := tea.KeyPressMsg{Code: 0, Text: "?"} - app.Update(keyMsg) - - if app.modal == nil { - t.Error("Expected modal to be set after ? key") - } - if app.currentView.StatusLine() != "Dashboard" { - t.Errorf("Expected currentView to remain Dashboard, got %s", app.currentView.StatusLine()) +func TestModalClosesWithKey(t *testing.T) { + tests := []struct { + name string + key tea.KeyPressMsg + }{ + {"q key", tea.KeyPressMsg{Code: 0, Text: "q"}}, + {"esc key", tea.KeyPressMsg{Code: tea.KeyEscape}}, + {"backspace", tea.KeyPressMsg{Code: tea.KeyBackspace}}, } -} - -func TestModalClosesWithQ(t *testing.T) { - ctx := context.Background() - reg := registry.New() - app := New(ctx, reg) - app.width = 100 - app.height = 50 - - dashboard := &MockView{name: "Dashboard"} - app.currentView = dashboard - modalContent := &MockView{name: "RegionSelector"} - app.modal = &view.Modal{Content: modalContent} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := newTestApp(t) + app.currentView = &MockView{name: "Dashboard"} + app.modal = &view.Modal{Content: &MockView{name: "TestModal"}} - qKeyMsg := tea.KeyPressMsg{Code: 0, Text: "q"} - app.Update(qKeyMsg) + app.Update(tt.key) - if app.modal != nil { - t.Error("Expected modal to be nil after q key") - } - if app.currentView.StatusLine() != "Dashboard" { - t.Errorf("Expected currentView to remain Dashboard, got %s", app.currentView.StatusLine()) + if app.modal != nil { + t.Errorf("Expected modal nil after %s", tt.name) + } + if app.currentView.StatusLine() != "Dashboard" { + t.Errorf("Expected currentView Dashboard, got %s", app.currentView.StatusLine()) + } + }) } } -func TestRegionChangedMsgClosesModal(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) - app.width = 100 - app.height = 50 - - dashboard := &MockView{name: "Dashboard"} - app.currentView = dashboard - modalContent := &MockView{name: "RegionSelector"} - app.modal = &view.Modal{Content: modalContent} - - msg := navmsg.RegionChangedMsg{Regions: []string{"us-west-2"}} - app.Update(msg) - - if app.modal != nil { - t.Error("Expected modal to be closed after RegionChangedMsg") +func TestMessageClosesModal(t *testing.T) { + tests := []struct { + name string + msg tea.Msg + }{ + {"RegionChangedMsg", navmsg.RegionChangedMsg{Regions: []string{"us-west-2"}}}, + {"ProfilesChangedMsg", navmsg.ProfilesChangedMsg{Selections: nil}}, } -} - -func TestProfilesChangedMsgClosesModal(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) - app.width = 100 - app.height = 50 - dashboard := &MockView{name: "Dashboard"} - app.currentView = dashboard - modalContent := &MockView{name: "ProfileSelector"} - app.modal = &view.Modal{Content: modalContent} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := newTestApp(t) + app.currentView = &MockView{name: "Dashboard"} + app.modal = &view.Modal{Content: &MockView{name: "TestModal"}} - msg := navmsg.ProfilesChangedMsg{Selections: nil} - app.Update(msg) + app.Update(tt.msg) - if app.modal != nil { - t.Error("Expected modal to be closed after ProfilesChangedMsg") + if app.modal != nil { + t.Errorf("Expected modal closed after %s", tt.name) + } + }) } } @@ -615,10 +520,7 @@ func TestWarningScreenDismissal(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - reg := registry.New() - - app := New(ctx, reg) + app := newTestApp(t) app.showWarnings = true app.warningsReady = true From 0b0b752286963bc6712568a252c014b92389b9b1 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 05:19:36 +0000 Subject: [PATCH 3/8] fix: restore Command key handler accidentally deleted in 68af343 - Add test to prevent future regression --- internal/app/app.go | 12 ++++++++++++ internal/app/app_test.go | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/internal/app/app.go b/internal/app/app.go index a6ca7a5f..969321a4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -231,6 +231,18 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.modal = &view.Modal{Content: helpView, Width: modalWidthHelp} return a, a.modal.SetSize(a.width, a.height) + case key.Matches(msg, a.keys.Command): + a.commandMode = true + // Set completion providers if current view is a ResourceBrowser + if rb, ok := a.currentView.(*view.ResourceBrowser); ok { + a.commandInput.SetTagProvider(rb) + a.commandInput.SetDiffProvider(rb) + } else { + a.commandInput.SetTagProvider(nil) + a.commandInput.SetDiffProvider(nil) + } + return a, a.commandInput.Activate() + case key.Matches(msg, a.keys.Region): regionSelector := view.NewRegionSelector(a.ctx) a.modal = &view.Modal{Content: regionSelector, Width: modalWidthRegion} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index e4c16417..c44ef4b1 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -456,6 +456,20 @@ func TestKeyOpensModal(t *testing.T) { } } +func TestCommandModeActivation(t *testing.T) { + app := newTestApp(t) + app.currentView = &MockView{name: "Dashboard"} + + app.Update(tea.KeyPressMsg{Code: 0, Text: ":"}) + + if !app.commandMode { + t.Error("Expected commandMode=true after ':' key") + } + if app.modal != nil { + t.Error("Expected no modal for command mode") + } +} + func TestModalClosesWithKey(t *testing.T) { tests := []struct { name string From e064236545920681bbad53e85b2827a0dc16f9e4 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 05:28:51 +0000 Subject: [PATCH 4/8] refactor: centralize modal width constants in view package --- internal/app/app.go | 12 +++--------- internal/view/modal.go | 5 +++++ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 969321a4..c0b6f118 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,12 +19,6 @@ import ( "github.com/clawscli/claws/internal/view" ) -const ( - modalWidthHelp = 70 - modalWidthRegion = 45 - modalWidthProfile = 55 -) - type clearErrorMsg struct{} // awsContextReadyMsg is sent when AWS context initialization completes @@ -228,7 +222,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, a.keys.Help): helpView := view.NewHelpView() - a.modal = &view.Modal{Content: helpView, Width: modalWidthHelp} + a.modal = &view.Modal{Content: helpView, Width: view.ModalWidthHelp} return a, a.modal.SetSize(a.width, a.height) case key.Matches(msg, a.keys.Command): @@ -245,7 +239,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, a.keys.Region): regionSelector := view.NewRegionSelector(a.ctx) - a.modal = &view.Modal{Content: regionSelector, Width: modalWidthRegion} + a.modal = &view.Modal{Content: regionSelector, Width: view.ModalWidthRegion} return a, tea.Batch( regionSelector.Init(), a.modal.SetSize(a.width, a.height), @@ -253,7 +247,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, a.keys.Profile): profileSelector := view.NewProfileSelector() - a.modal = &view.Modal{Content: profileSelector, Width: modalWidthProfile} + a.modal = &view.Modal{Content: profileSelector, Width: view.ModalWidthProfile} return a, tea.Batch( profileSelector.Init(), a.modal.SetSize(a.width, a.height), diff --git a/internal/view/modal.go b/internal/view/modal.go index 9ae93505..d3e44708 100644 --- a/internal/view/modal.go +++ b/internal/view/modal.go @@ -16,6 +16,11 @@ const ( modalDefaultWidth = 60 modalContentOffsetX = 3 modalContentOffsetY = 2 + + // Modal widths for specific views + ModalWidthHelp = 70 + ModalWidthRegion = 45 + ModalWidthProfile = 55 ) type Modal struct { From cbafc2b11859866bb4c500028c89a73104387d5a Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 06:05:02 +0000 Subject: [PATCH 5/8] fix: modal improvements and persistence bug - add modal stack for nested modals (ProfileDetail back to ProfileSelector) - add ModalWidthActionMenu=60, ModalWidthProfileDetail=65 - fix persistence overwriting unrelated config on region/profile change - remove dead ProfileSelector viewStack check --- internal/app/app.go | 42 ++++++++++++++++--------- internal/app/app_test.go | 33 +++++++++++++++++++ internal/view/detail_view.go | 2 +- internal/view/modal.go | 8 +++-- internal/view/profile_selector.go | 2 +- internal/view/resource_browser_input.go | 2 +- 6 files changed, 68 insertions(+), 21 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index c0b6f118..7f52bed1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -83,6 +83,7 @@ type App struct { profileRefreshError error modal *view.Modal + modalStack []*view.Modal modalRenderer *view.ModalRenderer styles appStyles @@ -323,11 +324,8 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case navmsg.RegionChangedMsg: log.Info("regions changed", "regions", msg.Regions) if config.File().PersistenceEnabled() { - profile := "" - if sel := config.Global().Selection(); sel.IsNamedProfile() { - profile = sel.ProfileName - } - config.File().SetStartup(msg.Regions, profile) + _, existingProfile := config.File().GetStartup() + config.File().SetStartup(msg.Regions, existingProfile) if err := config.File().Save(); err != nil { log.Warn("failed to persist config", "error", err) } @@ -357,8 +355,8 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(msg.Selections) > 0 && msg.Selections[0].IsNamedProfile() { profile = msg.Selections[0].ProfileName } - regions := config.Global().Regions() - config.File().SetStartup(regions, profile) + existingRegions, _ := config.File().GetStartup() + config.File().SetStartup(existingRegions, profile) if err := config.File().Save(); err != nil { log.Warn("failed to persist config", "error", err) } @@ -385,10 +383,6 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.currentView = a.viewStack[len(a.viewStack)-1] a.viewStack = a.viewStack[:len(a.viewStack)-1] - if _, ok := a.currentView.(*view.ProfileSelector); ok { - continue - } - if r, ok := a.currentView.(view.Refreshable); ok && r.CanRefresh() { cmds = append(cmds, a.currentView.SetSize(a.width, a.height-2), @@ -513,11 +507,18 @@ func (a *App) renderWarnings() string { func (a *App) handleModalUpdate(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case view.HideModalMsg: - a.modal = nil - return a, nil + return a.popModal() + + case view.ShowModalMsg: + if a.modal != nil { + a.modalStack = append(a.modalStack, a.modal) + } + a.modal = msg.Modal + return a, a.modal.SetSize(a.width, a.height) case view.NavigateMsg: a.modal = nil + a.modalStack = nil log.Debug("modal navigate", "clearStack", msg.ClearStack, "stackDepth", len(a.viewStack)) if msg.ClearStack { a.viewStack = nil @@ -532,10 +533,12 @@ func (a *App) handleModalUpdate(msg tea.Msg) (tea.Model, tea.Cmd) { case navmsg.RegionChangedMsg: a.modal = nil + a.modalStack = nil return a.Update(msg) case navmsg.ProfilesChangedMsg: a.modal = nil + a.modalStack = nil return a.Update(msg) case tea.KeyPressMsg: @@ -543,8 +546,7 @@ func (a *App) handleModalUpdate(msg tea.Msg) (tea.Model, tea.Cmd) { if ic, ok := a.modal.Content.(view.InputCapture); ok && ic.HasActiveInput() { break } - a.modal = nil - return a, nil + return a.popModal() } case tea.WindowSizeMsg: @@ -564,6 +566,16 @@ func (a *App) handleModalUpdate(msg tea.Msg) (tea.Model, tea.Cmd) { return a, cmd } +func (a *App) popModal() (tea.Model, tea.Cmd) { + if len(a.modalStack) > 0 { + a.modal = a.modalStack[len(a.modalStack)-1] + a.modalStack = a.modalStack[:len(a.modalStack)-1] + return a, a.modal.SetSize(a.width, a.height) + } + a.modal = nil + return a, nil +} + type keyMap struct { Up key.Binding Down key.Binding diff --git a/internal/app/app_test.go b/internal/app/app_test.go index c44ef4b1..6ce27c30 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -522,6 +522,39 @@ func TestMessageClosesModal(t *testing.T) { } } +func TestModalStackPushPop(t *testing.T) { + app := newTestApp(t) + app.currentView = &MockView{name: "Dashboard"} + + parentModal := &view.Modal{Content: &MockView{name: "ParentModal"}} + app.modal = parentModal + + childModal := &view.Modal{Content: &MockView{name: "ChildModal"}} + app.Update(view.ShowModalMsg{Modal: childModal}) + + if app.modal.Content.StatusLine() != "ChildModal" { + t.Errorf("Expected ChildModal, got %s", app.modal.Content.StatusLine()) + } + if len(app.modalStack) != 1 { + t.Errorf("Expected modalStack length 1, got %d", len(app.modalStack)) + } + + app.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + + if app.modal.Content.StatusLine() != "ParentModal" { + t.Errorf("Expected ParentModal after esc, got %s", app.modal.Content.StatusLine()) + } + if len(app.modalStack) != 0 { + t.Errorf("Expected empty modalStack, got %d", len(app.modalStack)) + } + + app.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + + if app.modal != nil { + t.Error("Expected modal nil after second esc") + } +} + func TestWarningScreenDismissal(t *testing.T) { tests := []struct { name string diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index d8a9c682..f4c38727 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -143,7 +143,7 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if actions := action.Global.Get(d.service, d.resType); len(actions) > 0 { actionMenu := NewActionMenu(d.ctx, dao.UnwrapResource(d.resource), d.service, d.resType) return d, func() tea.Msg { - return ShowModalMsg{Modal: &Modal{Content: actionMenu}} + return ShowModalMsg{Modal: &Modal{Content: actionMenu, Width: ModalWidthActionMenu}} } } } diff --git a/internal/view/modal.go b/internal/view/modal.go index d3e44708..f059e08e 100644 --- a/internal/view/modal.go +++ b/internal/view/modal.go @@ -18,9 +18,11 @@ const ( modalContentOffsetY = 2 // Modal widths for specific views - ModalWidthHelp = 70 - ModalWidthRegion = 45 - ModalWidthProfile = 55 + ModalWidthHelp = 70 + ModalWidthRegion = 45 + ModalWidthProfile = 55 + ModalWidthProfileDetail = 65 + ModalWidthActionMenu = 60 ) type Modal struct { diff --git a/internal/view/profile_selector.go b/internal/view/profile_selector.go index 1f20de04..7e3a22e9 100644 --- a/internal/view/profile_selector.go +++ b/internal/view/profile_selector.go @@ -355,6 +355,6 @@ func (p *ProfileSelector) toggleDetail() (tea.Model, tea.Cmd) { info, hasInfo := p.profileInfo[profile.id] detailView := NewProfileDetailView(profile, info, hasInfo) return p, func() tea.Msg { - return ShowModalMsg{Modal: &Modal{Content: detailView}} + return ShowModalMsg{Modal: &Modal{Content: detailView, Width: ModalWidthProfileDetail}} } } diff --git a/internal/view/resource_browser_input.go b/internal/view/resource_browser_input.go index 24ff2b9c..df8895ac 100644 --- a/internal/view/resource_browser_input.go +++ b/internal/view/resource_browser_input.go @@ -155,7 +155,7 @@ func (r *ResourceBrowser) handleAction() (tea.Model, tea.Cmd) { ctx, resource := r.contextForResource(r.filtered[r.table.Cursor()]) actionMenu := NewActionMenu(ctx, resource, r.service, r.resourceType) return r, func() tea.Msg { - return ShowModalMsg{Modal: &Modal{Content: actionMenu}} + return ShowModalMsg{Modal: &Modal{Content: actionMenu, Width: ModalWidthActionMenu}} } } } From 714ad298e3c1412bc42f844dee9ba52063ad364c Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 06:19:34 +0000 Subject: [PATCH 6/8] refactor: unify ShowModalMsg handling, add modal tests and docs - Consolidate ShowModalMsg handler logic (both paths now push to stack) - Add tests: TestShowModalFromNormalState, TestModalStackClearedOnRegion/ProfileChange - Document modal system in docs/architecture.md (stack flow, width constants) --- docs/architecture.md | 35 +++++++++++++++++++++--- internal/app/app.go | 3 +++ internal/app/app_test.go | 58 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index c1bfd38f..550356bd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -311,10 +311,37 @@ ui.DangerStyle() // Helper for error color | Detail View | Detailed resource information with scrolling | | Command Mode | `:` command input for navigation and sorting | | Filter Mode | `/` search input for filtering | -| Help View | `?` key bindings reference | -| Action Menu | `a` available actions for resource | -| Region Selector | `R` AWS region switching | -| Profile Selector | `P` AWS profile switching | +| Help View | `?` key bindings reference (modal) | +| Action Menu | `a` available actions for resource (modal) | +| Region Selector | `R` AWS region switching (modal) | +| Profile Selector | `P` AWS profile switching (modal) | + +### Modal System + +Some views (Help, Region Selector, Profile Selector, Action Menu) display as modals that overlay the current view rather than pushing to the view stack. + +**Key Characteristics:** +- Modals don't affect the view stack (`viewStack` remains unchanged) +- Support nesting via modal stack (e.g., Profile Selector → Profile Detail) +- Dismissed with `esc`, `q`, or `backspace` +- Automatically cleared on region/profile change + +**Modal Stack Flow:** +``` +┌─────────────────────────────────────────────────────────────┐ +│ ShowModalMsg → Push current modal to stack, show new │ +│ HideModalMsg → Pop stack (restore previous or close) │ +│ NavigateMsg → Clear stack, close all modals │ +│ Region/Profile → Clear stack, refresh underlying view │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Width Constants** (`internal/view/modal.go`): +- `ModalWidthHelp = 70` +- `ModalWidthRegion = 45` +- `ModalWidthProfile = 55` +- `ModalWidthProfileDetail = 65` +- `ModalWidthActionMenu = 60` ## Configuration diff --git a/internal/app/app.go b/internal/app/app.go index 7f52bed1..9c008a97 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -256,6 +256,9 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case view.ShowModalMsg: + if a.modal != nil { + a.modalStack = append(a.modalStack, a.modal) + } a.modal = msg.Modal return a, a.modal.SetSize(a.width, a.height) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 6ce27c30..6eb6f335 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -555,6 +555,64 @@ func TestModalStackPushPop(t *testing.T) { } } +func TestShowModalFromNormalState(t *testing.T) { + app := newTestApp(t) + app.currentView = &MockView{name: "Dashboard"} + app.modal = nil + app.modalStack = nil + + modal := &view.Modal{Content: &MockView{name: "TestModal"}} + app.Update(view.ShowModalMsg{Modal: modal}) + + if app.modal == nil { + t.Error("Expected modal to be set") + } + if app.modal.Content.StatusLine() != "TestModal" { + t.Errorf("Expected TestModal, got %s", app.modal.Content.StatusLine()) + } + if len(app.modalStack) != 0 { + t.Errorf("Expected empty modalStack when showing from normal state, got %d", len(app.modalStack)) + } +} + +func TestModalStackClearedOnRegionChange(t *testing.T) { + app := newTestApp(t) + app.currentView = &MockView{name: "Dashboard"} + + parentModal := &view.Modal{Content: &MockView{name: "ParentModal"}} + childModal := &view.Modal{Content: &MockView{name: "ChildModal"}} + app.modal = childModal + app.modalStack = []*view.Modal{parentModal} + + app.Update(navmsg.RegionChangedMsg{Regions: []string{"us-west-2"}}) + + if app.modal != nil { + t.Error("Expected modal nil after RegionChangedMsg") + } + if len(app.modalStack) != 0 { + t.Errorf("Expected empty modalStack after RegionChangedMsg, got %d", len(app.modalStack)) + } +} + +func TestModalStackClearedOnProfileChange(t *testing.T) { + app := newTestApp(t) + app.currentView = &MockView{name: "Dashboard"} + + parentModal := &view.Modal{Content: &MockView{name: "ParentModal"}} + childModal := &view.Modal{Content: &MockView{name: "ChildModal"}} + app.modal = childModal + app.modalStack = []*view.Modal{parentModal} + + app.Update(navmsg.ProfilesChangedMsg{Selections: nil}) + + if app.modal != nil { + t.Error("Expected modal nil after ProfilesChangedMsg") + } + if len(app.modalStack) != 0 { + t.Errorf("Expected empty modalStack after ProfilesChangedMsg, got %d", len(app.modalStack)) + } +} + func TestWarningScreenDismissal(t *testing.T) { tests := []struct { name string From c096dfc0aebff20b191ff97430a3e72d0b3608a7 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 06:29:32 +0000 Subject: [PATCH 7/8] fix: add profile region instead of replacing existing regions --- internal/app/app.go | 4 ++-- internal/config/config.go | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 9c008a97..9a1338b8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -315,7 +315,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } if msg.region != "" { - config.Global().SetRegion(msg.region) + config.Global().AddRegion(msg.region) } if len(msg.accountIDs) > 0 { for profileID, accountID := range msg.accountIDs { @@ -358,7 +358,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(msg.Selections) > 0 && msg.Selections[0].IsNamedProfile() { profile = msg.Selections[0].ProfileName } - existingRegions, _ := config.File().GetStartup() + existingRegions := config.Global().Regions() config.File().SetStartup(existingRegions, profile) if err := config.File().Save(); err != nil { log.Warn("failed to persist config", "error", err) diff --git a/internal/config/config.go b/internal/config/config.go index d158e08a..8612174a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "maps" "os" "regexp" + "slices" "sync" ) @@ -215,6 +216,15 @@ func (c *Config) SetRegion(region string) { doWithLock(&c.mu, func() { c.regions = []string{region} }) } +// AddRegion adds a region to the existing regions if not already present. +func (c *Config) AddRegion(region string) { + doWithLock(&c.mu, func() { + if !slices.Contains(c.regions, region) { + c.regions = append(c.regions, region) + } + }) +} + func (c *Config) SetRegions(regions []string) { doWithLock(&c.mu, func() { c.regions = append([]string(nil), regions...) }) } From 2efac5872ea4eded505d7828bee384303e2f5013 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 06:43:12 +0000 Subject: [PATCH 8/8] refactor: extract helpers to eliminate duplicate msg handling and recursion - Add clearModalState(), showModal(), handleNavigate() helpers - Add handleRegionChanged(), handleProfilesChanged(), popToRefreshableView() - Eliminate recursive Update() calls in handleModalUpdate - Reduce ShowModalMsg/NavigateMsg/RegionChangedMsg duplication --- internal/app/app.go | 233 ++++++++++++++++++++++---------------------- 1 file changed, 119 insertions(+), 114 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 9a1338b8..dd8bb945 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -256,25 +256,10 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case view.ShowModalMsg: - if a.modal != nil { - a.modalStack = append(a.modalStack, a.modal) - } - a.modal = msg.Modal - return a, a.modal.SetSize(a.width, a.height) + return a.showModal(msg.Modal) case view.NavigateMsg: - log.Debug("navigating", "clearStack", msg.ClearStack, "stackDepth", len(a.viewStack)) - if msg.ClearStack { - a.viewStack = nil - } else if a.currentView != nil { - a.viewStack = append(a.viewStack, a.currentView) - } - a.currentView = msg.View - cmds := []tea.Cmd{ - a.currentView.Init(), - a.currentView.SetSize(a.width, a.height-2), - } - return a, tea.Batch(cmds...) + return a.handleNavigate(msg) case view.ErrorMsg: log.Error("application error", "error", msg.Err) @@ -325,81 +310,10 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil case navmsg.RegionChangedMsg: - log.Info("regions changed", "regions", msg.Regions) - if config.File().PersistenceEnabled() { - _, existingProfile := config.File().GetStartup() - config.File().SetStartup(msg.Regions, existingProfile) - if err := config.File().Save(); err != nil { - log.Warn("failed to persist config", "error", err) - } - } - // Pop views until we find a refreshable one (ResourceBrowser or ServiceBrowser) - for len(a.viewStack) > 0 { - a.currentView = a.viewStack[len(a.viewStack)-1] - a.viewStack = a.viewStack[:len(a.viewStack)-1] - if r, ok := a.currentView.(view.Refreshable); ok && r.CanRefresh() { - return a, tea.Batch( - a.currentView.SetSize(a.width, a.height-2), - func() tea.Msg { return view.RefreshMsg{} }, - ) - } - } - // Fallback to dashboard if no refreshable view found - a.currentView = view.NewDashboardView(a.ctx, a.registry) - return a, tea.Batch( - a.currentView.Init(), - a.currentView.SetSize(a.width, a.height-2), - ) + return a.handleRegionChanged(msg) case navmsg.ProfilesChangedMsg: - log.Info("profiles changed", "count", len(msg.Selections)) - if config.File().PersistenceEnabled() { - profile := "" - if len(msg.Selections) > 0 && msg.Selections[0].IsNamedProfile() { - profile = msg.Selections[0].ProfileName - } - existingRegions := config.Global().Regions() - config.File().SetStartup(existingRegions, profile) - if err := config.File().Save(); err != nil { - log.Warn("failed to persist config", "error", err) - } - } - a.profileRefreshID++ - a.profileRefreshing = true - a.profileRefreshError = nil - refreshID := a.profileRefreshID - refreshCmd := func() tea.Msg { - ctx, cancel := context.WithTimeout(a.ctx, config.File().AWSInitTimeout()) - defer cancel() - region, accountIDs, err := aws.RefreshContextData(ctx) - return profileRefreshDoneMsg{ - refreshID: refreshID, - region: region, - accountIDs: accountIDs, - err: err, - } - } - - cmds := []tea.Cmd{refreshCmd} - - for len(a.viewStack) > 0 { - a.currentView = a.viewStack[len(a.viewStack)-1] - a.viewStack = a.viewStack[:len(a.viewStack)-1] - - if r, ok := a.currentView.(view.Refreshable); ok && r.CanRefresh() { - cmds = append(cmds, - a.currentView.SetSize(a.width, a.height-2), - func() tea.Msg { return view.RefreshMsg{} }, - ) - return a, tea.Batch(cmds...) - } - } - a.currentView = view.NewDashboardView(a.ctx, a.registry) - cmds = append(cmds, - a.currentView.Init(), - a.currentView.SetSize(a.width, a.height-2), - ) - return a, tea.Batch(cmds...) + return a.handleProfilesChanged(msg) case view.SortMsg: // Delegate sort command to current view @@ -513,36 +427,19 @@ func (a *App) handleModalUpdate(msg tea.Msg) (tea.Model, tea.Cmd) { return a.popModal() case view.ShowModalMsg: - if a.modal != nil { - a.modalStack = append(a.modalStack, a.modal) - } - a.modal = msg.Modal - return a, a.modal.SetSize(a.width, a.height) + return a.showModal(msg.Modal) case view.NavigateMsg: - a.modal = nil - a.modalStack = nil - log.Debug("modal navigate", "clearStack", msg.ClearStack, "stackDepth", len(a.viewStack)) - if msg.ClearStack { - a.viewStack = nil - } else if a.currentView != nil { - a.viewStack = append(a.viewStack, a.currentView) - } - a.currentView = msg.View - return a, tea.Batch( - a.currentView.Init(), - a.currentView.SetSize(a.width, a.height-2), - ) + a.clearModalState() + return a.handleNavigate(msg) case navmsg.RegionChangedMsg: - a.modal = nil - a.modalStack = nil - return a.Update(msg) + a.clearModalState() + return a.handleRegionChanged(msg) case navmsg.ProfilesChangedMsg: - a.modal = nil - a.modalStack = nil - return a.Update(msg) + a.clearModalState() + return a.handleProfilesChanged(msg) case tea.KeyPressMsg: if view.IsEscKey(msg) || msg.Code == tea.KeyBackspace || msg.String() == "q" { @@ -579,6 +476,114 @@ func (a *App) popModal() (tea.Model, tea.Cmd) { return a, nil } +func (a *App) clearModalState() { + a.modal = nil + a.modalStack = nil +} + +func (a *App) showModal(modal *view.Modal) (tea.Model, tea.Cmd) { + if a.modal != nil { + a.modalStack = append(a.modalStack, a.modal) + } + a.modal = modal + return a, a.modal.SetSize(a.width, a.height) +} + +func (a *App) handleNavigate(msg view.NavigateMsg) (tea.Model, tea.Cmd) { + log.Debug("navigating", "clearStack", msg.ClearStack, "stackDepth", len(a.viewStack)) + if msg.ClearStack { + a.viewStack = nil + } else if a.currentView != nil { + a.viewStack = append(a.viewStack, a.currentView) + } + a.currentView = msg.View + return a, tea.Batch( + a.currentView.Init(), + a.currentView.SetSize(a.width, a.height-2), + ) +} + +func (a *App) handleRegionChanged(msg navmsg.RegionChangedMsg) (tea.Model, tea.Cmd) { + log.Info("regions changed", "regions", msg.Regions) + if config.File().PersistenceEnabled() { + _, existingProfile := config.File().GetStartup() + config.File().SetStartup(msg.Regions, existingProfile) + if err := config.File().Save(); err != nil { + log.Warn("failed to persist config", "error", err) + } + } + return a.popToRefreshableView() +} + +func (a *App) handleProfilesChanged(msg navmsg.ProfilesChangedMsg) (tea.Model, tea.Cmd) { + log.Info("profiles changed", "count", len(msg.Selections)) + if config.File().PersistenceEnabled() { + profile := "" + if len(msg.Selections) > 0 && msg.Selections[0].IsNamedProfile() { + profile = msg.Selections[0].ProfileName + } + existingRegions := config.Global().Regions() + config.File().SetStartup(existingRegions, profile) + if err := config.File().Save(); err != nil { + log.Warn("failed to persist config", "error", err) + } + } + a.profileRefreshID++ + a.profileRefreshing = true + a.profileRefreshError = nil + refreshID := a.profileRefreshID + refreshCmd := func() tea.Msg { + ctx, cancel := context.WithTimeout(a.ctx, config.File().AWSInitTimeout()) + defer cancel() + region, accountIDs, err := aws.RefreshContextData(ctx) + return profileRefreshDoneMsg{ + refreshID: refreshID, + region: region, + accountIDs: accountIDs, + err: err, + } + } + + cmds := []tea.Cmd{refreshCmd} + + for len(a.viewStack) > 0 { + a.currentView = a.viewStack[len(a.viewStack)-1] + a.viewStack = a.viewStack[:len(a.viewStack)-1] + + if r, ok := a.currentView.(view.Refreshable); ok && r.CanRefresh() { + cmds = append(cmds, + a.currentView.SetSize(a.width, a.height-2), + func() tea.Msg { return view.RefreshMsg{} }, + ) + return a, tea.Batch(cmds...) + } + } + a.currentView = view.NewDashboardView(a.ctx, a.registry) + cmds = append(cmds, + a.currentView.Init(), + a.currentView.SetSize(a.width, a.height-2), + ) + return a, tea.Batch(cmds...) +} + +func (a *App) popToRefreshableView() (tea.Model, tea.Cmd) { + for len(a.viewStack) > 0 { + a.currentView = a.viewStack[len(a.viewStack)-1] + a.viewStack = a.viewStack[:len(a.viewStack)-1] + if r, ok := a.currentView.(view.Refreshable); ok && r.CanRefresh() { + return a, tea.Batch( + a.currentView.SetSize(a.width, a.height-2), + func() tea.Msg { return view.RefreshMsg{} }, + ) + } + } + a.currentView = view.NewDashboardView(a.ctx, a.registry) + return a, tea.Batch( + a.currentView.Init(), + a.currentView.SetSize(a.width, a.height-2), + ) +} + type keyMap struct { Up key.Binding Down key.Binding