diff --git a/internal/app/cursor.go b/internal/app/cursor.go index cf16e90b..6ab8f319 100644 --- a/internal/app/cursor.go +++ b/internal/app/cursor.go @@ -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. diff --git a/internal/app/filter_typeswitch_test.go b/internal/app/filter_typeswitch_test.go new file mode 100644 index 00000000..df47cafc --- /dev/null +++ b/internal/app/filter_typeswitch_test.go @@ -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) + } +} diff --git a/internal/app/tabs_portforward.go b/internal/app/tabs_portforward.go index 3fa4d524..dbfe1996 100644 --- a/internal/app/tabs_portforward.go +++ b/internal/app/tabs_portforward.go @@ -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__", diff --git a/internal/app/update_actions_security.go b/internal/app/update_actions_security.go index c86835bc..0edc7a7b 100644 --- a/internal/app/update_actions_security.go +++ b/internal/app/update_actions_security.go @@ -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, @@ -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) diff --git a/internal/app/update_navigation.go b/internal/app/update_navigation.go index 5c474c8a..a8f23b51 100644 --- a/internal/app/update_navigation.go +++ b/internal/app/update_navigation.go @@ -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: @@ -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 diff --git a/internal/app/whichkey.go b/internal/app/whichkey.go index 53fba5b9..4edd7af0 100644 --- a/internal/app/whichkey.go +++ b/internal/app/whichkey.go @@ -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 diff --git a/internal/app/whichkey_test.go b/internal/app/whichkey_test.go index 418d6327..451bbdde 100644 --- a/internal/app/whichkey_test.go +++ b/internal/app/whichkey_test.go @@ -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) + } +} + +// 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.