diff --git a/internal/config/validator.go b/internal/config/validator.go index 41cc263..8cf7b21 100644 --- a/internal/config/validator.go +++ b/internal/config/validator.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "regexp" "strings" @@ -697,6 +698,14 @@ func validateDNS1123Label(name, field, entityType string) *ValidationError { return nil } +// ValidateNamespaceName validates a Kubernetes namespace name (a DNS-1123 label). +func ValidateNamespaceName(name string) error { + if verr := validateDNS1123Label(name, "namespace", "Namespace"); verr != nil { + return errors.New(verr.Message) + } + return nil +} + // validateDNS1123Subdomain validates that a name is a valid DNS subdomain name (RFC 1123). // Used for resource names which can contain dots. func validateDNS1123Subdomain(name, field, entityType string) *ValidationError { diff --git a/internal/ui/wizard_handlers.go b/internal/ui/wizard_handlers.go index 3e2b150..9cd72a8 100644 --- a/internal/ui/wizard_handlers.go +++ b/internal/ui/wizard_handlers.go @@ -14,6 +14,9 @@ import ( "github.com/lukaszraczylo/kportal/internal/k8s" ) +// keyTypedText routes j and k to text input while a text field has focus. +const keyTypedText = "" + // isFilterableStep returns true if the step supports search/filter func isFilterableStep(step AddWizardStep) bool { switch step { @@ -339,7 +342,12 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } - switch msg.String() { + key := msg.String() + if (key == "j" || key == "k") && wizard.isTypingText() { + key = keyTypedText + } + + switch key { case "ctrl+c": // Hard cancel m.ui.viewMode = ViewModeMain @@ -373,8 +381,14 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Reset input mode based on the step we're going back to switch wizard.step { - case StepSelectContext, StepSelectNamespace, StepSelectResourceType: + case StepSelectContext, StepSelectResourceType: wizard.inputMode = InputModeList + case StepSelectNamespace: + if wizard.namespaceManual { + wizard.inputMode = InputModeText + } else { + wizard.inputMode = InputModeList + } case StepEnterResource: if wizard.selectedResourceType == ResourceTypeService { wizard.inputMode = InputModeList @@ -526,10 +540,27 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) { wizard.cursor = 0 wizard.clearSearchFilter() wizard.loading = true + wizard.namespaceManual = false + wizard.namespaceListErr = nil + wizard.inputMode = InputModeList return m, loadNamespacesCmd(m.ui.discovery, wizard.selectedContext) } case StepSelectNamespace: + if wizard.namespaceManual { + name := strings.TrimSpace(wizard.textInput) + if err := config.ValidateNamespaceName(name); err != nil { + wizard.error = err + return m, nil + } + wizard.error = nil + wizard.selectedNamespace = name + wizard.step = StepSelectResourceType + wizard.cursor = 0 + wizard.clearTextInput() + wizard.inputMode = InputModeList + return m, nil + } filteredNamespaces := wizard.getFilteredNamespaces() if wizard.cursor >= 0 && wizard.cursor < len(filteredNamespaces) { wizard.selectedNamespace = filteredNamespaces[wizard.cursor] @@ -863,10 +894,18 @@ func (m model) handleNamespacesLoaded(msg NamespacesLoadedMsg) (tea.Model, tea.C defer m.ui.mu.Unlock() if m.ui.addWizard != nil { - m.ui.addWizard.loading = false - m.ui.addWizard.error = msg.err + w := m.ui.addWizard + w.loading = false + w.error = nil if msg.err == nil { - m.ui.addWizard.namespaces = msg.namespaces + w.namespaces = msg.namespaces + } + // Without permission to list namespaces the user types the name instead. + if (msg.err != nil || len(msg.namespaces) == 0) && w.step == StepSelectNamespace { + w.namespaceManual = true + w.namespaceListErr = msg.err + w.inputMode = InputModeText + w.textInput = "" } } diff --git a/internal/ui/wizard_manual_namespace_test.go b/internal/ui/wizard_manual_namespace_test.go new file mode 100644 index 0000000..1c6b9b8 --- /dev/null +++ b/internal/ui/wizard_manual_namespace_test.go @@ -0,0 +1,104 @@ +package ui + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func typeKeys(t *testing.T, m model, text string) { + t.Helper() + for _, r := range text { + m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } +} + +func TestNamespacesLoaded_ListFailureSwitchesToManualEntry(t *testing.T) { + m := newModelWithWizard(StepSelectNamespace) + m.ui.addWizard.loading = true + + m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("namespaces is forbidden")}) + + w := m.ui.addWizard + assert.True(t, w.namespaceManual) + assert.Equal(t, InputModeText, w.inputMode) + assert.NoError(t, w.error) + assert.False(t, w.loading) + assert.Contains(t, m.renderSelectNamespace(), "Enter Namespace") + assert.Contains(t, m.renderSelectNamespace(), "forbidden") +} + +func TestNamespacesLoaded_EmptyListSwitchesToManualEntry(t *testing.T) { + m := newModelWithWizard(StepSelectNamespace) + + m.handleNamespacesLoaded(NamespacesLoadedMsg{namespaces: []string{}}) + + assert.True(t, m.ui.addWizard.namespaceManual) +} + +func TestNamespacesLoaded_ListedNamespacesKeepSelectionMode(t *testing.T) { + m := newModelWithWizard(StepSelectNamespace) + + m.handleNamespacesLoaded(NamespacesLoadedMsg{namespaces: []string{"default", "kube-system"}}) + + w := m.ui.addWizard + assert.False(t, w.namespaceManual) + assert.Equal(t, InputModeList, w.inputMode) + assert.Equal(t, []string{"default", "kube-system"}, w.namespaces) +} + +func TestManualNamespace_TypeAndConfirm(t *testing.T) { + m := newModelWithWizard(StepSelectNamespace) + m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("forbidden")}) + + // k and j must be typed, not treated as navigation + typeKeys(t, m, "kube-jack") + assert.Equal(t, "kube-jack", m.ui.addWizard.textInput) + + m.handleAddWizardEnter() + + w := m.ui.addWizard + assert.Equal(t, "kube-jack", w.selectedNamespace) + assert.Equal(t, StepSelectResourceType, w.step) + assert.Equal(t, InputModeList, w.inputMode) + assert.Empty(t, w.textInput) +} + +func TestManualNamespace_RejectsInvalidName(t *testing.T) { + m := newModelWithWizard(StepSelectNamespace) + m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("forbidden")}) + m.ui.addWizard.selectedNamespace = "" + + for _, bad := range []string{"", "Has_Upper", "-leading", "trailing-"} { + m.ui.addWizard.textInput = bad + m.handleAddWizardEnter() + + w := m.ui.addWizard + require.Error(t, w.error, "name %q", bad) + assert.Equal(t, StepSelectNamespace, w.step, "name %q", bad) + assert.Empty(t, w.selectedNamespace, "name %q", bad) + } +} + +func TestManualNamespace_BackFromLaterStepRestoresTextInput(t *testing.T) { + m := newModelWithWizard(StepSelectResourceType) + m.ui.addWizard.namespaceManual = true + + m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyEsc}) + + w := m.ui.addWizard + assert.Equal(t, StepSelectNamespace, w.step) + assert.Equal(t, InputModeText, w.inputMode) +} + +func TestKeysJK_StillNavigateInListMode(t *testing.T) { + m := newModelWithWizard(StepSelectResourceType) + m.ui.addWizard.inputMode = InputModeList + + m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + + assert.Equal(t, 1, m.ui.addWizard.cursor) +} diff --git a/internal/ui/wizard_state.go b/internal/ui/wizard_state.go index a323abb..a013580 100644 --- a/internal/ui/wizard_state.go +++ b/internal/ui/wizard_state.go @@ -111,6 +111,7 @@ func (r ResourceType) Description() string { // AddWizardState maintains the state for the add port forward wizard type AddWizardState struct { error error + namespaceListErr error httpLogOriginal *config.HTTPLogSpec resourceValue string originalID string @@ -137,6 +138,7 @@ type AddWizardState struct { confirmationFocus ConfirmationFocus portAvailable bool isEditing bool + namespaceManual bool loading bool httpLog bool } @@ -223,6 +225,13 @@ func (w *AddWizardState) handleTextInput(char rune) { } } +// isTypingText reports whether printable keys must go into a text field, +// including keys that otherwise navigate (j, k). +func (w *AddWizardState) isTypingText() bool { + return w.inputMode == InputModeText || + (w.step == StepConfirmation && w.confirmationFocus == FocusAlias) +} + // clearTextInput clears the text input field func (w *AddWizardState) clearTextInput() { w.textInput = "" diff --git a/internal/ui/wizard_views.go b/internal/ui/wizard_views.go index f454b3f..18ce81b 100644 --- a/internal/ui/wizard_views.go +++ b/internal/ui/wizard_views.go @@ -142,6 +142,10 @@ func (m model) renderSelectNamespace() string { b.WriteString(renderHeader("Add Port Forward", renderProgress(2, 7))) fmt.Fprintf(&b, "Context: %s\n\n", breadcrumbStyle.Render(wizard.selectedContext)) + if wizard.namespaceManual { + return m.renderManualNamespace(&b) + } + b.WriteString("Select Namespace:\n\n") // Show search input if there's a filter active @@ -177,6 +181,32 @@ func (m model) renderSelectNamespace() string { return b.String() } +// renderManualNamespace shows a free-text namespace prompt for clusters where +// the namespace list is not available to the user. +func (m model) renderManualNamespace(b *strings.Builder) string { + wizard := m.ui.addWizard + + b.WriteString("Enter Namespace:\n\n") + if wizard.namespaceListErr != nil { + b.WriteString(mutedStyle.Render(fmt.Sprintf("Could not list namespaces (%v).", wizard.namespaceListErr))) + } else { + b.WriteString(mutedStyle.Render("No namespaces found.")) + } + b.WriteString("\n") + b.WriteString(mutedStyle.Render("Type the namespace name instead.")) + b.WriteString("\n\n") + + b.WriteString(renderTextInput("Namespace: ", wizard.textInput, wizard.textInput != "")) + b.WriteString("\n") + if wizard.error != nil { + b.WriteString(errorStyle.Render(fmt.Sprintf("✗ %v\n", wizard.error))) + } + + b.WriteString("\n") + b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth))) + return b.String() +} + func (m model) renderSelectResourceType() string { wizard := m.ui.addWizard var b strings.Builder