diff --git a/docs/architecture.md b/docs/architecture.md index c1bfd38..550356b 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 4b3a793..dd8bb94 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,7 +19,6 @@ import ( "github.com/clawscli/claws/internal/view" ) -// clearErrorMsg is sent to clear transient errors after a timeout type clearErrorMsg struct{} // awsContextReadyMsg is sent when AWS context initialization completes @@ -84,6 +83,7 @@ type App struct { profileRefreshError error modal *view.Modal + modalStack []*view.Modal modalRenderer *view.ModalRenderer styles appStyles @@ -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: view.ModalWidthHelp} + return a, a.modal.SetSize(a.width, a.height) case key.Matches(msg, a.keys.Command): a.commandMode = true @@ -244,44 +240,26 @@ 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: view.ModalWidthRegion} 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: view.ModalWidthProfile} return a, tea.Batch( - a.currentView.Init(), - a.currentView.SetSize(a.width, a.height-2), + profileSelector.Init(), + a.modal.SetSize(a.width, a.height), ) } case view.ShowModalMsg: - 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) @@ -322,7 +300,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 { @@ -332,88 +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() { - profile := "" - if sel := config.Global().Selection(); sel.IsNamedProfile() { - profile = sel.ProfileName - } - config.File().SetStartup(msg.Regions, profile) - 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 - } - regions := config.Global().Regions() - config.File().SetStartup(regions, 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 _, 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), - 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 @@ -524,30 +424,29 @@ 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: + return a.showModal(msg.Modal) case view.NavigateMsg: - a.modal = 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.clearModalState() + return a.handleRegionChanged(msg) + + case navmsg.ProfilesChangedMsg: + a.clearModalState() + return a.handleProfilesChanged(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() { break } - a.modal = nil - return a, nil + return a.popModal() } case tea.WindowSizeMsg: @@ -567,6 +466,124 @@ 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 +} + +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 diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 90ca469..6eb6f33 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,6 +425,194 @@ func TestModalNavigateClosesModal(t *testing.T) { } } +func TestKeyOpensModal(t *testing.T) { + tests := []struct { + name string + key string + }{ + {"region selector", "R"}, + {"profile selector", "P"}, + {"help view", "?"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := newTestApp(t) + app.currentView = &MockView{name: "Dashboard"} + app.viewStack = nil + + app.Update(tea.KeyPressMsg{Code: 0, Text: tt.key}) + + 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 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 + key tea.KeyPressMsg + }{ + {"q key", tea.KeyPressMsg{Code: 0, Text: "q"}}, + {"esc key", tea.KeyPressMsg{Code: tea.KeyEscape}}, + {"backspace", tea.KeyPressMsg{Code: tea.KeyBackspace}}, + } + + 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"}} + + app.Update(tt.key) + + 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 TestMessageClosesModal(t *testing.T) { + tests := []struct { + name string + msg tea.Msg + }{ + {"RegionChangedMsg", navmsg.RegionChangedMsg{Regions: []string{"us-west-2"}}}, + {"ProfilesChangedMsg", navmsg.ProfilesChangedMsg{Selections: nil}}, + } + + 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"}} + + app.Update(tt.msg) + + if app.modal != nil { + t.Errorf("Expected modal closed after %s", tt.name) + } + }) + } +} + +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 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 @@ -477,10 +625,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 diff --git a/internal/config/config.go b/internal/config/config.go index d158e08..8612174 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...) }) } diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index d8a9c68..f4c3872 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 9ae9350..f059e08 100644 --- a/internal/view/modal.go +++ b/internal/view/modal.go @@ -16,6 +16,13 @@ const ( modalDefaultWidth = 60 modalContentOffsetX = 3 modalContentOffsetY = 2 + + // Modal widths for specific views + 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 1f20de0..7e3a22e 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 24ff2b9..df8895a 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}} } } }