Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -1056,7 +1056,7 @@ The Scale overlay edits the HPA's `spec.minReplicas` / `spec.maxReplicas` (the H
`e` ConfigMap Editor, `v` Describe, `E` Edit, `D` Delete, `l` Labels / Annotations, `P` Permissions, `b` Debug Pod, `V` Events

### Node Actions
`c` Cordon/Uncordon (toggle schedulability), `n` Drain, `t` Taints (editor: mark taints for removal with `space`, add with `a`, apply with `enter`), `s` Shell, `v` Describe, `E` Edit, `b` Debug Pod, `V` Events
`c` Cordon/Uncordon (toggle schedulability), `n` Drain, `t` Taints (editor: mark taints for removal with `space`, add with `a`, pick a common taint with `p`, apply with `enter`), `s` Shell, `v` Describe, `E` Edit, `b` Debug Pod, `V` Events
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Drain streams the eviction progress live: in PTY terminal mode (default on macOS/Linux) the `kubectl drain` output renders in lfk's embedded scrollable terminal; in Exec mode the host terminal is handed over. The same applies to Drain Node on a Karpenter NodeClaim.

Expand Down
1 change: 1 addition & 0 deletions internal/app/app_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const (
overlayLogTopProfile // single-select log format profile picker for Log Top
overlayLogTopColumns // show/hide and reorder column picker for Log Top
overlayTaintEditor // node taint editor (action menu key t on a Node)
overlayTaintPresets // common-taint picker over the taint editor (p key)
)

// whoCanState groups the reverse-RBAC ("Who-Can") fields so they live
Expand Down
2 changes: 2 additions & 0 deletions internal/app/overlay_hintbar.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ func (m Model) overlayHintBarSelector() string {
return m.renderHints(copyFieldPickerHints())
case overlayTaintEditor:
return m.renderHints(m.taintEditorHints())
case overlayTaintPresets:
return m.renderHints(taintPresetHints())
case overlayPortForward:
return m.renderHints([]ui.HintEntry{
{Key: "j/k", Desc: "select port"},
Expand Down
3 changes: 3 additions & 0 deletions internal/app/taint_editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ type taintEditorState struct {
addEff int // index into model.ValidTaintEffects
loading bool
seq int // fetch sequence — stale taintsLoadedMsg are dropped
// Common-taint picker (overlayTaintPresets), opened over the editor.
presetCursor int
presetScroll int
}

// taintsLoadedMsg carries the fetched node taints for the editor.
Expand Down
3 changes: 3 additions & 0 deletions internal/app/taint_editor_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ func (m Model) handleTaintEditorKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "a":
p.focus = taintFocusKey
return m, nil
case "p":
return m.openTaintPresets()
case " ":
if p.cursor < 0 || p.cursor >= n {
return m, nil
Expand Down Expand Up @@ -175,6 +177,7 @@ func (m Model) taintEditorHints() []ui.HintEntry {
return []ui.HintEntry{
{Key: "space", Desc: "mark remove"},
{Key: "a", Desc: "add"},
{Key: "p", Desc: "common taints"},
{Key: "j/k", Desc: "navigate"},
{Key: "enter", Desc: "apply"},
{Key: "esc", Desc: "cancel"},
Expand Down
149 changes: 149 additions & 0 deletions internal/app/taint_presets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package app

import (
"slices"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"

"github.com/janosmiko/lfk/internal/model"
"github.com/janosmiko/lfk/internal/ui"
)

// handleTaintOverlayKey and renderTaintOverlay route the editor and its
// picker as one feature. The dispatchers they hang off (handleOverlayKeySecondary,
// renderOverlayContentExtended) sit at the gocyclo cap, so the two overlays
// share one case there and split here instead.
func (m Model) handleTaintOverlayKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.overlay == overlayTaintPresets {
return m.handleTaintPresetKey(msg)
}
return m.handleTaintEditorKey(msg)
}

func (m Model) renderTaintOverlay() (string, int, int) {
if m.overlay == overlayTaintPresets {
return m.renderOverlayTaintPresets()
}
return m.renderOverlayTaintEditor()
}

// openTaintPresets opens the common-taint picker over the editor. The editor
// state stays live underneath so Esc returns to the list exactly as it was.
func (m Model) openTaintPresets() (tea.Model, tea.Cmd) {
m.taintEditor.presetCursor = 0
m.taintEditor.presetScroll = 0
m.overlay = overlayTaintPresets
return m, nil
}

// closeTaintPresets returns to the editor without adopting anything — the
// picker writes to the add-row only on an explicit selection.
func (m *Model) closeTaintPresets() {
m.overlay = overlayTaintEditor
}

// taintPresetsMaxVisible caps the picker rows shown at once, shared by the
// renderer and the scroll clamp so the scrollbar and cursor stay in sync.
func (m Model) taintPresetsMaxVisible() int {
return min(12, max(m.height-12, 3))
}

// handleTaintPresetKey drives the common-taint picker: vim navigation, Enter
// to adopt the highlighted preset, Esc to back out.
func (m Model) handleTaintPresetKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
p := &m.taintEditor
n := len(model.CommonTaints)
visible := m.taintPresetsMaxVisible()
switch msg.String() {
case "esc", "q":
m.closeTaintPresets()
return m, nil
case "enter":
return m.applyTaintPreset()
case "j", "down":
if p.presetCursor < n-1 {
p.presetCursor++
}
case "k", "up":
if p.presetCursor > 0 {
p.presetCursor--
}
case "g", "home":
p.presetCursor = 0
case "G", "end":
p.presetCursor = max(n-1, 0)
case "ctrl+d":
p.presetCursor = min(p.presetCursor+visible/2, max(n-1, 0))
case "ctrl+u":
p.presetCursor = max(p.presetCursor-visible/2, 0)
case "ctrl+f", "pgdown", "shift+down":
p.presetCursor = min(p.presetCursor+visible, max(n-1, 0))
case "ctrl+b", "pgup", "shift+up":
p.presetCursor = max(p.presetCursor-visible, 0)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
p.presetCursor = max(0, min(p.presetCursor, n-1))
overlayListScroll(&p.presetScroll, p.presetCursor, n, visible)
return m, nil
}

// applyTaintPreset copies the highlighted preset into the add-row and returns
// to the editor with the key field focused. The preset is a starting point,
// not a commitment: all three fields stay editable and the taint still goes
// through the normal validation and staging path.
func (m Model) applyTaintPreset() (tea.Model, tea.Cmd) {
p := &m.taintEditor
if p.presetCursor < 0 || p.presetCursor >= len(model.CommonTaints) {
m.closeTaintPresets()
return m, nil
}
t := model.CommonTaints[p.presetCursor].Taint
p.addKey = t.Key
p.addVal = t.Value
p.addEff = max(slices.Index(model.ValidTaintEffects, t.Effect), 0)
p.focus = taintFocusKey
m.closeTaintPresets()
return m, nil
}

// renderOverlayTaintPresets paints the common-taint picker: each preset in
// kubectl notation with a note on what applying it does.
func (m Model) renderOverlayTaintPresets() (string, int, int) {
if !m.taintEditor.active {
return "", 0, 0
}
p := m.taintEditor
items := make([]ui.OverlayListItem, len(model.CommonTaints))
for i, preset := range model.CommonTaints {
items[i] = ui.OverlayListItem{
Name: preset.Taint.String(),
Description: preset.Desc,
}
}
cfg := ui.OverlayListConfig{
Title: "Common taints",
Cursor: p.presetCursor,
ShowDescription: true,
Scroll: p.presetScroll,
MaxVisible: m.taintPresetsMaxVisible(),
}

overlayW := ui.OverlayListWidth(items, cfg, max(m.width-10, 1))
innerW := max(overlayW-4, 1)
for i := range items {
items[i].Name = ui.Truncate(items[i].Name, innerW)
}

content := ui.RenderOverlayList(items, cfg, innerW)
overlayH := min(lipgloss.Height(content)+2, max(m.height-4, 3))
return content, overlayW, overlayH
}

// taintPresetHints is the picker's bottom hint bar.
func taintPresetHints() []ui.HintEntry {
return []ui.HintEntry{
{Key: "j/k", Desc: "navigate"},
{Key: "enter", Desc: "use taint"},
{Key: "esc", Desc: "back"},
}
}
131 changes: 131 additions & 0 deletions internal/app/taint_presets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package app

import (
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/janosmiko/lfk/internal/model"
)

// taintPresetKey drives the preset picker, which owns its own overlay and so
// routes through the overlay dispatcher rather than the editor's handler.
func taintPresetKey(t *testing.T, m Model, keys ...string) Model {
t.Helper()
for _, k := range keys {
var msg tea.KeyMsg
switch k {
case "enter":
msg = tea.KeyMsg{Type: tea.KeyEnter}
case "esc":
msg = tea.KeyMsg{Type: tea.KeyEsc}
default:
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)}
}
mdl, _ := m.handleTaintPresetKey(msg)
var ok bool
m, ok = mdl.(Model)
require.True(t, ok)
}
return m
}

func TestTaintEditor_POpensPresetPicker(t *testing.T) {
m := openTaintEditorLoaded(t, taintEditorTestModel())
m = taintKey(t, m, "p")

assert.Equal(t, overlayTaintPresets, m.overlay)
assert.True(t, m.taintEditor.active, "the editor stays alive underneath")
}

// Selecting a preset fills all three add-row fields and drops the user on the
// key field, so every part stays editable before staging.
func TestTaintPresets_SelectFillsAddRowAndFocusesKey(t *testing.T) {
m := openTaintEditorLoaded(t, taintEditorTestModel())
m = taintKey(t, m, "p")
m = taintPresetKey(t, m, "j", "enter")

want := model.CommonTaints[1].Taint
assert.Equal(t, overlayTaintEditor, m.overlay)
assert.Equal(t, want.Key, m.taintEditor.addKey)
assert.Equal(t, want.Value, m.taintEditor.addVal)
assert.Equal(t, want.Effect, model.ValidTaintEffects[m.taintEditor.addEff])
assert.Equal(t, taintFocusKey, m.taintEditor.focus)
}

// A picked preset is a starting point, not a commitment: it must still go
// through the normal validation and staging path.
func TestTaintPresets_PickedPresetStages(t *testing.T) {
m := openTaintEditorLoaded(t, taintEditorTestModel())
before := len(m.taintEditor.rows)
m = taintKey(t, m, "p")
m = taintPresetKey(t, m, "enter")
m = taintKey(t, m, "enter")

require.Len(t, m.taintEditor.rows, before+1)
staged := m.taintEditor.rows[before]
assert.True(t, staged.staged)
assert.Equal(t, model.CommonTaints[0].Taint.String(), staged.taint.String())
}

// Esc backs out to the editor without adopting the highlighted preset — the
// picker only writes to the add-row on an explicit selection.
func TestTaintPresets_EscAdoptsNothing(t *testing.T) {
m := openTaintEditorLoaded(t, taintEditorTestModel())
m = taintKey(t, m, "p")
m = taintPresetKey(t, m, "j", "esc")

assert.Equal(t, overlayTaintEditor, m.overlay)
assert.Empty(t, m.taintEditor.addKey, "esc must not fill the add-row")
assert.Equal(t, taintFocusList, m.taintEditor.focus)
}

func TestTaintPresets_CursorClampsAtBothEnds(t *testing.T) {
m := openTaintEditorLoaded(t, taintEditorTestModel())
m = taintKey(t, m, "p")

m = taintPresetKey(t, m, "k")
assert.Equal(t, 0, m.taintEditor.presetCursor, "cursor clamps at the top")

m = taintPresetKey(t, m, "G")
assert.Equal(t, len(model.CommonTaints)-1, m.taintEditor.presetCursor)

m = taintPresetKey(t, m, "j")
assert.Equal(t, len(model.CommonTaints)-1, m.taintEditor.presetCursor, "cursor clamps at the bottom")

m = taintPresetKey(t, m, "g")
assert.Equal(t, 0, m.taintEditor.presetCursor)
}

func TestRenderOverlayTaintPresets_ShowsTaintsAndDescriptions(t *testing.T) {
m := openTaintEditorLoaded(t, taintEditorTestModel())
m = taintKey(t, m, "p")

content, w, h := m.renderOverlayTaintPresets()
require.NotEmpty(t, content)
assert.Positive(t, w)
assert.Positive(t, h)

plain := stripANSI(content)
assert.Contains(t, plain, "node-role.kubernetes.io/control-plane:NoSchedule")
assert.Contains(t, plain, "Reserve control-plane nodes")
}

// The picker's hints must describe the picker, not the editor underneath.
func TestTaintPresets_HintBar(t *testing.T) {
m := openTaintEditorLoaded(t, taintEditorTestModel())
m = taintKey(t, m, "p")

hints := stripANSI(m.overlayHintBar())
assert.Contains(t, hints, "use taint")
}

// The editor's own hint bar must advertise the picker, or nobody finds it.
func TestTaintEditor_HintBarAdvertisesPresets(t *testing.T) {
m := openTaintEditorLoaded(t, taintEditorTestModel())

hints := stripANSI(m.overlayHintBar())
assert.Contains(t, hints, "common taints")
}
4 changes: 2 additions & 2 deletions internal/app/update_overlays.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,8 @@ func (m Model) handleOverlayKeySecondary(msg tea.KeyMsg) (tea.Model, tea.Cmd, bo
case overlayCopyField:
mdl, cmd := m.handleCopyFieldPickerKey(msg)
return mdl, cmd, true
case overlayTaintEditor:
mdl, cmd := m.handleTaintEditorKey(msg)
case overlayTaintEditor, overlayTaintPresets:
mdl, cmd := m.handleTaintOverlayKey(msg)
return mdl, cmd, true
}
return m, nil, false
Expand Down
4 changes: 2 additions & 2 deletions internal/app/view_overlays.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,8 @@ func (m Model) renderOverlayContentExtended() (string, int, int, bool) {
case overlayCopyField:
c, w, h := m.renderOverlayCopyField()
return c, w, h, true
case overlayTaintEditor:
c, w, h := m.renderOverlayTaintEditor()
case overlayTaintEditor, overlayTaintPresets:
c, w, h := m.renderTaintOverlay()
return c, w, h, true
case overlayLogTopGroupBy:
c, w, h := m.renderLogTopGroupByOverlay()
Expand Down
Loading