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
15 changes: 15 additions & 0 deletions internal/app/cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,21 @@ func (m *Model) saveLevelFilter() {
m.filterMemory[key] = savedFilter{text: m.filterText, broad: m.filterBroadMode}
}

// resetFilterForTypeSwitch remembers the current list's committed filter for
// back-nav restore, then clears all live filter/search state so the next list
// starts clean. Every path landing on a different resource list must call it
// BEFORE mutating m.nav — saveLevelFilter keys off the old position (TASK-839).
func (m *Model) resetFilterForTypeSwitch() {
m.saveLevelFilter()
m.filterText = ""
m.filterInput.Clear()
m.filterActive = false
m.filterBroadMode = false
m.activeFilterPreset = nil
m.unfilteredMiddleItems = nil
m.searchInput.Clear()
}

// restoreLevelFilter applies the saved filter for the current navigation path,
// or clears the live filter if none was saved (so a sibling list never inherits
// another list's filter). Must be called AFTER the destination level is set.
Expand Down
125 changes: 125 additions & 0 deletions internal/app/filter_typeswitch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package app

import (
"testing"

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

// These tests pin TASK-839's rule across every path that lands the user on a
// different resource list: the committed quick filter of the origin list must
// never leak into the destination.

// Regular navigation: jobs (filtered) -> h to types -> descend into deployments.
func TestNavigateChild_TypeSwitchClearsFilter(t *testing.T) {
m := gotoTestModel()
m.discoveredResources["ctx"] = append(m.discoveredResources["ctx"],
model.ResourceTypeEntry{Kind: "Job", APIGroup: "batch", APIVersion: "v1", Resource: "jobs", Namespaced: true})
typesItems := model.BuildSidebarItems(m.discoveredResources["ctx"])
m.setMiddleItems(typesItems)
m.pushLeft()
m.nav.Level = model.LevelResources
m.nav.ResourceType = model.ResourceTypeEntry{Kind: "Job", APIGroup: "batch", APIVersion: "v1", Resource: "jobs", Namespaced: true}
m.filterText = "my-job"
m.filterInput.Set("my-job")

out, _ := m.navigateParent()
m = out.(Model)
if m.filterText != "" {
t.Fatalf("after back-nav to types, filterText=%q", m.filterText)
}

dep := model.ResourceTypeEntry{Kind: "Deployment", APIGroup: "apps", APIVersion: "v1", Resource: "deployments", Namespaced: true}
for i, item := range m.visibleMiddleItems() {
if item.Extra == dep.ResourceRef() {
m.setCursor(i)
break
}
}
out, _ = m.navigateChild()
m = out.(Model)
if m.filterText != "" || m.filterInput.Value != "" {
t.Fatalf("regular navigation leaked the filter: filterText=%q input=%q", m.filterText, m.filterInput.Value)
}
}

// Security-finding teleport: Enter on an affected resource must not carry the
// finding view's filter into the real resource list.
func TestJumpToFindingResource_ClearsQuickFilter(t *testing.T) {
m := baseModelBoost2()
m.discoveredResources["test-ctx"] = []model.ResourceTypeEntry{
{Kind: "Deployment", APIGroup: "apps", APIVersion: "v1", Resource: "deployments", Namespaced: true},
}
m.nav.Level = model.LevelOwned
m.nav.ResourceType = model.ResourceTypeEntry{Kind: "__security_falco__", APIGroup: "_security"}
m.nav.ResourceName = "privileged"
m.securityActiveGroup = "privileged"
m.filterText = "api"
m.filterInput.Set("api")
sel := &model.Item{
Kind: "__security_affected_resource__",
Name: "deploy/api",
Namespace: "prod",
Columns: []model.KeyValue{
{Key: "__resource_key__", Value: "prod/Deployment/api"},
},
}
m.middleItems = []model.Item{*sel}
m.setCursor(0)

out, _ := m.jumpToFindingResource(sel)
rm := out.(Model)
if rm.nav.Level != model.LevelResources {
t.Fatalf("expected teleport to LevelResources, got %v", rm.nav.Level)
}
if rm.filterText != "" || rm.filterInput.Value != "" {
t.Fatalf("finding teleport leaked the filter: filterText=%q input=%q", rm.filterText, rm.filterInput.Value)
}
}

// Security-findings teleport: opening a resource's findings pseudo-list must
// not keep the origin list's filter or preset state.
func TestOpenSecurityFindingsForResource_ClearsQuickFilter(t *testing.T) {
m := baseModelBoost2()
m.nav.Level = model.LevelResources
m.nav.ResourceType = model.ResourceTypeEntry{Kind: "Pod", APIVersion: "v1", Resource: "pods", Namespaced: true}
m.filterText = "nginx"
m.filterInput.Set("nginx")
m.filterBroadMode = true
m.activeFilterPreset = &FilterPreset{Name: "p"}
m.unfilteredMiddleItems = []model.Item{{Name: "pod-a"}}

rm, _ := m.openSecurityFindingsForResource(
[]security.ResourceRef{{Namespace: "prod", Kind: "Pod", Name: "api"}}, "Pod", "api")
if rm.nav.ResourceType.Kind != model.SecurityResourceFindingsKind {
t.Fatalf("expected findings pseudo type, got %q", rm.nav.ResourceType.Kind)
}
if rm.filterText != "" || rm.filterInput.Value != "" || rm.filterActive || rm.filterBroadMode {
t.Fatalf("findings teleport leaked filter state: text=%q input=%q active=%v broad=%v",
rm.filterText, rm.filterInput.Value, rm.filterActive, rm.filterBroadMode)
}
if rm.activeFilterPreset != nil || rm.unfilteredMiddleItems != nil {
t.Fatal("findings teleport must clear filter preset state")
}
}

// Port-forwards teleport: opening the Port Forwards pseudo-list must not keep
// the origin list's filter (it would hide the forwards).
func TestNavigateToPortForwards_ClearsQuickFilter(t *testing.T) {
m := baseModelWithFakeClient()
m.portForwardMgr = k8s.NewPortForwardManager()
m.nav.Level = model.LevelResources
m.nav.ResourceType = model.ResourceTypeEntry{Kind: "Pod", APIVersion: "v1", Resource: "pods", Namespaced: true}
m.filterText = "nginx"
m.filterInput.Set("nginx")

m.navigateToPortForwards()
if m.nav.ResourceType.Kind != "__port_forwards__" {
t.Fatalf("expected port-forwards pseudo type, got %q", m.nav.ResourceType.Kind)
}
if m.filterText != "" || m.filterInput.Value != "" {
t.Fatalf("port-forwards teleport leaked the filter: filterText=%q input=%q", m.filterText, m.filterInput.Value)
}
}
3 changes: 3 additions & 0 deletions internal/app/tabs_portforward.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ func (m *Model) navigateToPortForwards() {
resourceTypes = model.BuildSidebarItems(model.SeedResources())
}

// The origin list's quick filter must not carry into the port-forwards
// pseudo-list (TASK-839 class).
m.resetFilterForTypeSwitch()
m.nav.ResourceType = model.ResourceTypeEntry{
DisplayName: "Port Forwards",
Kind: "__port_forwards__",
Expand Down
4 changes: 3 additions & 1 deletion internal/app/update_actions_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ func (m Model) openSecurityFindingsForResource(refs []security.ResourceRef, kind
m.saveCursor()
// Record the origin BEFORE any nav mutation so JumpBack restores it.
m.pushJumpHistory()
// The origin list's quick filter must not carry into the findings
// pseudo-list (TASK-839 class); keyed off the origin, so before unwind.
m.resetFilterForTypeSwitch()
m.unwindToResourcesLevel()
m.nav.ResourceType = model.ResourceTypeEntry{
DisplayName: "Findings: " + kind + "/" + name,
Expand All @@ -76,7 +79,6 @@ func (m Model) openSecurityFindingsForResource(refs []security.ResourceRef, kind
m.securityResourceFilter = refs
m.securityActiveGroup = ""
m.securityActiveSource = ""
m.filterText = ""
m.clearRight()
m.setMiddleItems(nil)
m.setCursor(0)
Expand Down
25 changes: 7 additions & 18 deletions internal/app/update_navigation.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,24 +264,10 @@ func (m Model) navigateChild() (tea.Model, tea.Cmd) {
ui.ActiveMiddleScroll = 0
ui.ActiveLeftScroll = 0

// Remember this level's filter before clearing it, so navigating back
// (navigateParent) restores the list exactly as the user left it.
m.saveLevelFilter()

// Clear filter when navigating into a child.
m.filterText = ""
m.filterInput.Clear()
m.filterActive = false
m.activeFilterPreset = nil
m.unfilteredMiddleItems = nil

// Clear search highlight on level change so it doesn't bleed onto
// the child level — opening a resource is a "fresh start" for the
// user (issue requested fix). The Esc cascade in handleExplorerEsc
// already clears search as its own step before navigating parent,
// but programmatic navigateChild/navigateParent paths previously
// preserved searchInput.Value, leaving the highlight stuck.
m.searchInput.Clear()
// Remember this level's filter, then clear all live filter/search state so
// the child level is a fresh start; navigating back (navigateParent)
// restores the list exactly as the user left it.
m.resetFilterForTypeSwitch()

switch m.nav.Level {
case model.LevelClusters:
Expand Down Expand Up @@ -687,6 +673,9 @@ func (m Model) jumpToFindingResource(sel *model.Item) (tea.Model, tea.Cmd) {
// security view from LevelResourceTypes). After this the Esc cascade
// behaves as if the user came from LevelResourceTypes directly.
m.popLeft()
// The finding view's quick filter must not carry into the real resource
// list it teleports to (TASK-839 class).
m.resetFilterForTypeSwitch()
m.nav.ResourceType = rt
m.nav.ResourceName = ""
m.nav.Namespace = namespace
Expand Down
4 changes: 4 additions & 0 deletions internal/app/whichkey.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ func (m Model) gotoResourceType(kind, apiGroup string) (tea.Model, tea.Cmd) {
return m, scheduleStatusClear()
}
m.saveCursor()
// Start the destination list clean (TASK-839). Deliberately no
// restoreLevelFilter here — like a descend, a goto is a fresh start; only
// back-nav (navigateParent) recalls a saved filter.
m.resetFilterForTypeSwitch()
m.nav.ResourceType = rt
m.applyResourceTypeSortDefault(m.nav.ResourceType, m.nav.Context)
m.nav.Level = model.LevelResources
Expand Down
60 changes: 60 additions & 0 deletions internal/app/whichkey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,66 @@ func TestGotoResourceType_LeftPaneIsResourceTypes(t *testing.T) {
}
}

// TestGotoResourceType_ClearsQuickFilter verifies that a goto jump between
// resource types clears the committed quick filter instead of leaking it into
// the destination list (TASK-839: pods' filter hid every deployment after gd).
// The old level's filter must still be remembered for back-nav restore.
func TestGotoResourceType_ClearsQuickFilter(t *testing.T) {
m := gotoTestModel()
m.nav.Level = model.LevelResources
m.nav.ResourceType = model.ResourceTypeEntry{Kind: "Pod", Resource: "pods", APIVersion: "v1", Namespaced: true}
m.filterText = "nginx"
m.filterInput.Set("nginx")
m.filterBroadMode = true
m.searchInput.Set("nginx")
m.activeFilterPreset = &FilterPreset{Name: "p"}
m.unfilteredMiddleItems = []model.Item{{Name: "pod-a"}}
oldKey := m.navKey()

out, _ := m.gotoResourceType("Deployment", "apps")
rm := out.(Model)

if rm.filterText != "" || rm.filterInput.Value != "" {
t.Fatalf("quick filter leaked into destination: filterText=%q filterInput=%q", rm.filterText, rm.filterInput.Value)
}
if rm.filterActive {
t.Fatal("filterActive must be false after a goto jump")
}
if rm.filterBroadMode {
t.Fatal("filterBroadMode must not carry into the destination")
}
if rm.activeFilterPreset != nil || rm.unfilteredMiddleItems != nil {
t.Fatal("filter preset state must be cleared by a goto jump")
}
if rm.searchInput.Value != "" {
t.Fatalf("search highlight must not bleed into destination, got %q", rm.searchInput.Value)
}
if f, ok := rm.filterMemory[oldKey]; !ok || f.text != "nginx" || !f.broad {
t.Fatalf("old level's filter (incl. broad mode) must be saved for back-nav restore; got %+v (ok=%v)", f, ok)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// TestGotoResourceType_DoesNotRestoreDestinationFilter pins the product
// decision that a goto is a fresh start like a descend: a filter previously
// committed on the destination list is NOT re-applied by the jump (only
// back-nav via navigateParent restores saved filters).
func TestGotoResourceType_DoesNotRestoreDestinationFilter(t *testing.T) {
m := gotoTestModel()
m.nav.Level = model.LevelResources
m.nav.ResourceType = model.ResourceTypeEntry{Kind: "Pod", Resource: "pods", APIVersion: "v1", Namespaced: true}
// Simulate a filter remembered for the Deployments list from an earlier visit.
probe := m
probe.nav.ResourceType = model.ResourceTypeEntry{Kind: "Deployment", APIGroup: "apps", APIVersion: "v1", Resource: "deployments", Namespaced: true}
m.filterMemory = map[string]savedFilter{probe.navKey(): {text: "old-deploy-filter"}}

out, _ := m.gotoResourceType("Deployment", "apps")
rm := out.(Model)

if rm.filterText != "" || rm.filterInput.Value != "" {
t.Fatalf("goto must not restore the destination's saved filter; got filterText=%q filterInput=%q", rm.filterText, rm.filterInput.Value)
}
}

// TestGotoResourceType_BackNavLandsOnJumpedType verifies that after jumping to a
// resource type via goto (gv) and pressing h/left to go back, the cursor lands on
// the resource type that was jumped to rather than a stale or default highlight.
Expand Down