From 539d8b80c82077114dcfd47191057cc0f58d8a0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1nos=20Mik=C3=B3?= Date: Fri, 17 Jul 2026 09:05:56 +0200 Subject: [PATCH 1/2] fix(explorer): clear quick filter when goto jumps between resource types A committed quick filter stayed in m.filterText across a g-prefix goto (e.g. gd from pods to deployments), silently hiding every row in the destination list. gotoResourceType now mirrors the descend path's filter bookkeeping: save the old level's filter for back-nav restore, then start the destination clean (filter, preset, search highlight). Closes TASK-839 --- internal/app/whichkey.go | 13 ++++++++ internal/app/whichkey_test.go | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/internal/app/whichkey.go b/internal/app/whichkey.go index 53fba5b9..e91faf90 100644 --- a/internal/app/whichkey.go +++ b/internal/app/whichkey.go @@ -100,6 +100,19 @@ func (m Model) gotoResourceType(kind, apiGroup string) (tea.Model, tea.Cmd) { return m, scheduleStatusClear() } m.saveCursor() + // Mirror the descend path's filter bookkeeping: remember this level's + // committed filter for back-nav restore, then start the destination list + // clean so the old type's quick filter doesn't silently hide every row + // (TASK-839). Deliberately no restoreLevelFilter here — like a descend, a + // goto is a fresh start; only back-nav (navigateParent) recalls a saved + // filter. + m.saveLevelFilter() + m.filterText = "" + m.filterInput.Clear() + m.filterActive = false + m.activeFilterPreset = nil + m.unfilteredMiddleItems = nil + m.searchInput.Clear() 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..6632541d 100644 --- a/internal/app/whichkey_test.go +++ b/internal/app/whichkey_test.go @@ -298,6 +298,62 @@ 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.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.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" { + t.Fatalf("old level's filter 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. From e1d74acc6f6d8585b808a12634a732456c10e758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1nos=20Mik=C3=B3?= Date: Fri, 17 Jul 2026 19:39:10 +0200 Subject: [PATCH 2/2] fix(explorer): clear quick filter on every resource-type switch path UAT feedback on the goto fix: the filter must start clean on any path that lands on a different resource list, not just g-prefix chords. Extract the save-then-clear bookkeeping into resetFilterForTypeSwitch and use it on every type-switch path: regular descend (navigateChild), goto chords, the security-finding teleport, the per-resource findings view, and the port-forwards list. The last three previously leaked the origin list's filter. Broad-match mode now also clears (and is saved for back-nav restore), per CodeRabbit review. Bookmark jumps and session restore intentionally keep applying their own saved filters. --- internal/app/cursor.go | 15 +++ internal/app/filter_typeswitch_test.go | 125 ++++++++++++++++++++++++ internal/app/tabs_portforward.go | 3 + internal/app/update_actions_security.go | 4 +- internal/app/update_navigation.go | 25 ++--- internal/app/whichkey.go | 17 +--- internal/app/whichkey_test.go | 8 +- 7 files changed, 163 insertions(+), 34 deletions(-) create mode 100644 internal/app/filter_typeswitch_test.go 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 e91faf90..4edd7af0 100644 --- a/internal/app/whichkey.go +++ b/internal/app/whichkey.go @@ -100,19 +100,10 @@ func (m Model) gotoResourceType(kind, apiGroup string) (tea.Model, tea.Cmd) { return m, scheduleStatusClear() } m.saveCursor() - // Mirror the descend path's filter bookkeeping: remember this level's - // committed filter for back-nav restore, then start the destination list - // clean so the old type's quick filter doesn't silently hide every row - // (TASK-839). Deliberately no restoreLevelFilter here — like a descend, a - // goto is a fresh start; only back-nav (navigateParent) recalls a saved - // filter. - m.saveLevelFilter() - m.filterText = "" - m.filterInput.Clear() - m.filterActive = false - m.activeFilterPreset = nil - m.unfilteredMiddleItems = nil - m.searchInput.Clear() + // 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 6632541d..451bbdde 100644 --- a/internal/app/whichkey_test.go +++ b/internal/app/whichkey_test.go @@ -308,6 +308,7 @@ func TestGotoResourceType_ClearsQuickFilter(t *testing.T) { 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"}} @@ -322,14 +323,17 @@ func TestGotoResourceType_ClearsQuickFilter(t *testing.T) { 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" { - t.Fatalf("old level's filter must be saved for back-nav restore; got %+v (ok=%v)", f, ok) + 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) } }