feat(tui): add label and milestone filter pane to issues browser#163
Conversation
Adds an interactive filter pane to the mgw:issues TUI browser that lets users narrow the issue list by labels, milestones, and state in real time. - New `lib/tui/filter.cjs`: FilterState class extracts available labels and milestones from the loaded issue set, manages active selections, applies filters client-side before fuzzy search runs, and persists last-used selections to .mgw/config.json - `lib/tui/renderer.cjs`: filter pane overlay (replaces detail pane when focused) shows label/milestone checkboxes and state radio buttons with cursor highlighting; filter bar summary row updates to reflect active filters; help overlay updated with new shortcuts - `lib/tui/index.cjs`: FilterState wired into browser state; filter-then- search pipeline (filterState.apply → FuzzySearch); keyboard handlers for filter-focus, filter-toggle, filter-clear, filter-scroll-down/up, filter-next/prev-section; filter selections persist on close - `lib/tui/keyboard.cjs`: added [f] filter-focus, [Space] filter-toggle, [c] filter-clear, and Ctrl+C force-quit bindings Closes #119 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
snipcodeit
left a comment
There was a problem hiding this comment.
Review: PR #163 — label and milestone filter pane
The FilterState class is well-designed — clean separation of concerns, good persistence contract, and the filter logic (AND for labels, OR for milestones) is correct. However there is one critical runtime bug and several gaps.
CRITICAL: FuzzySearch.searchIn does not exist
In lib/tui/index.cjs, _applyFiltersAndSearch() calls:
return query ? search.searchIn(afterFilter, query) : afterFilter;But FuzzySearch (in lib/tui/search.cjs) only exposes a search(query) method that searches this.items — it has no searchIn(subset, query) method. This will throw:
TypeError: search.searchIn is not a function
at runtime the moment a filter is active and the user types a search query. The PR body even acknowledges this: "FuzzySearch does not have a searchIn method — we need a helper that creates a temporary FuzzySearch over a subset." — but no helper was implemented.
Fix options:
- Add a
searchIn(items, query)method toFuzzySearch:searchIn(items, query) { const temp = new FuzzySearch(items, { keys: this.keys }); return temp.search(query); }
- Or construct a new
FuzzySearchinline in_applyFiltersAndSearch:function _applyFiltersAndSearch() { const afterFilter = filterState.apply(issues); if (!query) return afterFilter; const scoped = new FuzzySearch(afterFilter, { keys: ['title', 'number', 'labels'] }); return scoped.search(query); }
Option 1 is cleaner and keeps search.cjs as the single source of search logic.
CRITICAL: No test file added for filter.cjs
FilterState is a pure in-memory class with no I/O dependencies — it is ideal for unit testing. The PR test plan lists 17 testable assertions but none are implemented. test/tui-filter.test.cjs should cover:
extractLabels/extractMilestonesdeduplication and sort- Label toggle (add/remove)
- Milestone toggle
- State filter selection via
toggleCursor() apply()— AND logic for labels, OR logic for milestones, state filteringclearAll()resets everythingtoJSON()/ constructor hydration round-tripisEmptygetter
These are all synchronous, no mocking needed.
CRITICAL: _savePersistedFilters is never called
_savePersistedFilters(filterState) is defined in lib/tui/index.cjs but is never called in the keyboard handler wiring shown in the diff. There is no filter-toggle or filter-clear handler that calls it after mutations. Without saves, the persistence feature (one of the stated test plan items) is non-functional.
Expected pattern in each mutating handler:
keyboard.on('filter-toggle', () => {
filterState.toggleCursor();
_savePersistedFilters(filterState);
filtered = _applyFiltersAndSearch();
draw();
});
keyboard.on('filter-clear', () => {
filterState.clearAll();
_savePersistedFilters(filterState);
filtered = _applyFiltersAndSearch();
draw();
});filtered variable not updated on filter change
The existing filtered variable is initialized as search.search(query) (searching all issues). When filters are applied via _applyFiltersAndSearch(), the result should replace filtered. But the keyboard handlers need to explicitly reassign filtered = _applyFiltersAndSearch() after any filter mutation. Verify all mutating handlers do this.
Merge order dependency on #162
This PR references helpVisible and PAGE_SIZE which are introduced in PR #162. Ensure #162 is merged first. If merged out of order, this PR will fail at runtime.
Good things
FilterStateclass design is excellent — clean cursor model, good section abstraction- Label AND / Milestone OR filter semantics are explicitly documented and correct
- Persisted filter hydration clamps to available options (prevents stale selections)
clearAll()correctly resetsactiveStateto'open'(not'all')toJSON()returning plain arrays (not Sets) is correct for JSON serialization
…terState tests - Fix _applyFiltersAndSearch() to call _searchIn() instead of the non-existent search.searchIn() method (TypeError at runtime) - Add _savePersistedFilters() call to filter-toggle handler so label/ milestone selections are persisted to .mgw/config.json on each toggle, matching the existing behaviour in filter-clear and select/quit handlers - Add test/filter.test.cjs: 42 unit tests covering FilterState construction (label/milestone extraction, persisted state restore), toggleCursor() add/remove semantics, apply() with label/state/milestone/combined filters, clearAll(), isEmpty, and toJSON() round-trip serialisation
Summary
lib/tui/filter.cjs— a pureFilterStateclass that extracts available labels and milestones from the loaded issue set, manages active selections with keyboard-navigable cursor, applies filters client-side before fuzzy search runs, and persists last-used selections to.mgw/config.jsonrenderer.cjsthat replaces the detail pane when focused — shows label/milestone checkboxes ([x]) and state radio buttons with>cursor highlighting, plus a filter bar summary row showing active filtersindex.cjs:filterState.apply(issues)narrows the list beforeFuzzySearchruns, so search works within the filtered view; filter selections persist on pane close[f]filter-focus,[Space]filter-toggle,[c]filter-clear, andCtrl+Cforce-quit bindings tokeyboard.cjsCloses #119
Milestone Context
Changes
lib/tui/filter.cjs(new)FilterStateclass:availableLabels,availableMilestonesextracted from issue set at constructioncursorDown(),cursorUp(),nextSection(),prevSection()move through Labels/Milestones/State sectionstoggleCursor()— toggles label/milestone membership, selects state optionapply(issues)— filters by active labels (AND), active milestones (OR), and state; runs before fuzzy searchclearAll()— resets all selectionstoJSON()/ constructor hydration — persistence contract for.mgw/config.jsonextractLabels()andextractMilestones()— exported helperslib/tui/renderer.cjsfilterPane(blessed box, overlays detail pane whenfocusPane === 'filter'): renders label checkboxes, milestone checkboxes, state radio buttons with cursor indicators_formatFilterPane(filterState)— builds pane content from FilterState_formatFilterBar(filterState)— builds summary row (e.g.Filter: Labels: bug | Milestone: v4)render()to show/hide filter pane vs detail pane based onfocusPane; passesfilterStatethroughstartKeyboard()updated: in filter mode,j/k/↑/↓emitfilter-scroll-*; Tab/Shift+Tab emitfilter-next/prev-section[f] filterhintlib/tui/index.cjsFilterStatefrom./filter.cjs_loadPersistedFilters()/_savePersistedFilters()— read/write.mgw/config.jsontuiFilterskeyinitialFilterflags seed the FilterState (label, milestone, state)refilter()— recalculatesfiltered = searchIn(filterState.apply(issues), query)after any filter or search changefilter-focus,filter-scroll-down/up,filter-next/prev-section,filter-toggle,filter-clearhelp,jump-top/bottom,page-up/down,tab-focus-reverse,force-quithandlers addedlib/tui/keyboard.cjs'f': 'filter-focus',' ': 'filter-toggle','c': 'filter-clear','\u0003': 'force-quit'Test Plan
MGW_NO_TUI=1 node -e "require('./lib/tui/filter.cjs')"— module loads without errorfs.toggleCursor()on labelbug→fs.activeLabelshasbug; toggle again → removedfs.activeState = 'closed'thenfs.apply(issues)returns only closed issuesv4→ only issues with that milestone returnedfs.clearAll()→isEmpty === truefs.toJSON()/ constructor hydration round-trip — active selections survive JSON serialisationMGW_NO_TUI=1 node bin/mgw.cjs issues --limit 5— static table renders (no filter pane, no error)fopens filter pane (detail pane replaced by filter pane with label/milestone/state sections)j/knavigate cursor within filter pane rowsTabadvances cursor to next section (Labels → Milestones → State → Labels)Spacetoggles selected label/milestone; issue list updates in real timeSpaceon State section selects that state option; list updatesfagain closes filter pane (returns to detail pane)cclears all active filters from anywhere; list returns to full set.mgw/config.json🤖 Generated with Claude Code