Compare commits

..
1 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub 4748bc4be5 feat(ui): type the namespace when the cluster does not list them (#84)
Some users cannot list namespaces on a cluster. The add wizard showed
an error and offered no way forward.

The namespace step now switches to a text prompt when the list call
fails or returns nothing. The prompt shows the reason and checks that
the name is a valid DNS label. Esc from a later step returns to the
prompt.

The j and k keys now type into text fields. They moved the list cursor
before, so a name such as kube-system could not be typed.

Closes #66
2026-10-02 02:28:58 +01:00
5 changed files with 196 additions and 5 deletions
+9
View File
@@ -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 {
+44 -5
View File
@@ -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 = "<typed-text>"
// 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 = ""
}
}
+104
View File
@@ -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)
}
+9
View File
@@ -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 = ""
+30
View File
@@ -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