feat(ui): toggle httpLog per-forward in add/edit wizard

Adds an HTTP-log enable toggle to the wizard's confirmation step so
users can flip httpLog on a forward without editing YAML by hand.

Behaviour:
- 'h' on the confirmation step toggles HTTPLog when not focused on
  the alias text input. When focus is on alias, 'h' is treated as
  text so users can still type aliases like 'host' or 'http-proxy'.
- The confirmation summary shows '[x] enabled' or '[ ] disabled'.
- New forwards: toggle on -> &HTTPLogSpec{Enabled: true}; off -> nil.
- Edit mode: pre-populates the toggle from the existing forward and
  preserves any advanced HTTPLog fields the user had configured in
  YAML (logFile, includeHeaders, maxBodySize, filterPath) by copying
  the original spec on save. Toggling off discards the advanced
  fields (consistent with 'absent in YAML = disabled').

State changes:
- ForwardStatus gains *config.HTTPLogSpec so the wizard can see the
  full original spec on edit.
- AddWizardState gains httpLog bool + httpLogOriginal *HTTPLogSpec.

Three new tests:
- TestHandleAddWizardKeys_HToggleHTTPLog
- TestHandleAddWizardKeys_HOnAliasFocusIsTextInput
- TestEditPrefill_PreservesHTTPLog
This commit is contained in:
2026-05-06 12:41:36 +01:00
parent c413b808f1
commit bfe541565b
6 changed files with 136 additions and 6 deletions
+1
View File
@@ -200,6 +200,7 @@ func (ui *BubbleTeaUI) AddForward(id string, fwd *config.Forward) {
Alias: alias,
Type: resourceType,
Resource: resourceName,
HTTPLog: fwd.HTTPLog,
RemotePort: fwd.Port,
LocalPort: fwd.LocalPort,
Status: "Starting",
+80
View File
@@ -997,3 +997,83 @@ func TestHandleRemoveWizardKeys_EnterOnYesStillConfirms(t *testing.T) {
assert.NotNil(t, cmd, "Enter on Yes must still dispatch removeForwardsCmd")
}
// TestHandleAddWizardKeys_HToggleHTTPLog verifies that pressing 'h' on the
// confirmation step (when not focused on the alias text input) flips the
// httpLog flag on the wizard state.
func TestHandleAddWizardKeys_HToggleHTTPLog(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.step = StepConfirmation
ui.addWizard.confirmationFocus = FocusButtons
ui.addWizard.inputMode = InputModeList
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
require.False(t, m.ui.addWizard.httpLog, "httpLog should default to false")
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}
m.handleAddWizardKeys(keyMsg)
assert.True(t, m.ui.addWizard.httpLog, "first 'h' should enable httpLog")
m.handleAddWizardKeys(keyMsg)
assert.False(t, m.ui.addWizard.httpLog, "second 'h' should disable httpLog")
}
// TestHandleAddWizardKeys_HOnAliasFocusIsTextInput verifies that 'h' is
// treated as a regular character when the alias text input has focus, so the
// user can still type aliases like "host" or "http-proxy".
func TestHandleAddWizardKeys_HOnAliasFocusIsTextInput(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.step = StepConfirmation
ui.addWizard.confirmationFocus = FocusAlias
ui.addWizard.inputMode = InputModeList
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}
m.handleAddWizardKeys(keyMsg)
assert.False(t, m.ui.addWizard.httpLog, "httpLog must NOT toggle when alias has focus")
assert.Contains(t, m.ui.addWizard.textInput, "h", "'h' should land in alias text input")
}
// TestEditPrefill_PreservesHTTPLog verifies that opening the wizard in edit
// mode for a forward whose ForwardStatus has HTTPLog set initialises the
// wizard's httpLog flag and httpLogOriginal pointer correctly.
func TestEditPrefill_PreservesHTTPLog(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
disco := &k8s.Discovery{}
ui.SetWizardDependencies(disco, &config.Mutator{}, "/path/to/config")
fwd := &config.Forward{
Resource: "pod/api",
Port: 8080,
LocalPort: 8080,
HTTPLog: &config.HTTPLogSpec{Enabled: true, IncludeHeaders: true, MaxBodySize: 4096},
}
ui.AddForward("api", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}
m.handleMainViewKeys(keyMsg)
require.NotNil(t, m.ui.addWizard, "wizard should be active after 'e'")
assert.True(t, m.ui.addWizard.isEditing)
assert.True(t, m.ui.addWizard.httpLog, "httpLog flag should reflect existing forward")
require.NotNil(t, m.ui.addWizard.httpLogOriginal, "original spec should be retained for advanced fields")
assert.True(t, m.ui.addWizard.httpLogOriginal.IncludeHeaders)
assert.Equal(t, 4096, m.ui.addWizard.httpLogOriginal.MaxBodySize)
}
+1
View File
@@ -11,6 +11,7 @@ import (
// ForwardStatus represents the current status of a port forward
type ForwardStatus struct {
HTTPLog *config.HTTPLogSpec
Context string
Namespace string
Alias string
+39
View File
@@ -119,6 +119,8 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.ui.addWizard.remotePort = selectedForward.RemotePort
m.ui.addWizard.localPort = selectedForward.LocalPort
m.ui.addWizard.alias = selectedForward.Alias
m.ui.addWizard.httpLogOriginal = selectedForward.HTTPLog
m.ui.addWizard.httpLog = selectedForward.HTTPLog != nil && selectedForward.HTTPLog.Enabled
// Determine resource type from the resource string
if strings.HasPrefix(selectedForward.Type, "service") {
@@ -429,6 +431,29 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
case "h":
// In confirmation step (when not typing into the alias field), 'h'
// toggles whether this forward has HTTP traffic logging enabled.
// When the alias field is focused, fall through to text input below.
if wizard.step == StepConfirmation && wizard.confirmationFocus != FocusAlias {
wizard.httpLog = !wizard.httpLog
return m, nil
}
// Otherwise treat as text input (filter or alias).
canTypeText := wizard.inputMode == InputModeText ||
(wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias) ||
(wizard.inputMode == InputModeList && isFilterableStep(wizard.step))
if canTypeText {
if wizard.inputMode == InputModeList && isFilterableStep(wizard.step) {
wizard.searchFilter += "h"
wizard.cursor = 0
wizard.scrollOffset = 0
} else {
wizard.handleTextInput('h')
}
}
return m, nil
case "enter":
return m.handleAddWizardEnter()
@@ -671,6 +696,20 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
fwd.Resource = "service/" + wizard.resourceValue
}
// HTTPLog: when toggled on, preserve any advanced fields the
// user had configured in YAML (logFile, includeHeaders, etc.)
// so the wizard does not silently strip them. When toggled
// off, leave HTTPLog nil (= absent in YAML = disabled).
if wizard.httpLog {
if wizard.httpLogOriginal != nil {
spec := *wizard.httpLogOriginal
spec.Enabled = true
fwd.HTTPLog = &spec
} else {
fwd.HTTPLog = &config.HTTPLogSpec{Enabled: true}
}
}
wizard.loading = true
// If editing, use atomic update operation
+8 -5
View File
@@ -3,6 +3,7 @@ package ui
import (
"strings"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
@@ -110,6 +111,7 @@ func (r ResourceType) Description() string {
// AddWizardState maintains the state for the add port forward wizard
type AddWizardState struct {
error error
httpLogOriginal *config.HTTPLogSpec
resourceValue string
originalID string
portCheckMsg string
@@ -119,16 +121,16 @@ type AddWizardState struct {
selector string
selectedContext string
selectedNamespace string
pods []k8s.PodInfo
contexts []string
services []k8s.ServiceInfo
detectedPorts []k8s.PortInfo
matchingPods []k8s.PodInfo
services []k8s.ServiceInfo
contexts []string
namespaces []string
scrollOffset int
pods []k8s.PodInfo
localPort int
selectedResourceType ResourceType
step AddWizardStep
localPort int
scrollOffset int
cursor int
remotePort int
inputMode InputMode
@@ -136,6 +138,7 @@ type AddWizardState struct {
portAvailable bool
isEditing bool
loading bool
httpLog bool
}
// newAddWizardState creates a new add wizard state initialized to the first step
+7 -1
View File
@@ -510,6 +510,12 @@ func (m model) renderConfirmation() string {
b.WriteString(fmt.Sprintf(" Local Port: %d\n", wizard.localPort))
b.WriteString(" Protocol: tcp\n")
httpLogMark := "[ ] disabled"
if wizard.httpLog {
httpLogMark = "[x] enabled"
}
b.WriteString(fmt.Sprintf(" HTTP Log: %s\n", httpLogMark))
b.WriteString("\n")
// Show alias field with focus indicator
@@ -538,7 +544,7 @@ func (m model) renderConfirmation() string {
}
b.WriteString("\n")
b.WriteString(wrapHelpText("↑/↓/Tab: Navigate Enter: Confirm Esc: Back", wizardHelpWidth(m.termWidth)))
b.WriteString(wrapHelpText("↑/↓/Tab: Navigate h: Toggle HTTP Log Enter: Confirm Esc: Back", wizardHelpWidth(m.termWidth)))
return b.String()
}