Skip to content

fix(code-browser): preserve state across toggle/switch and filter-click (#817, #818)#826

Draft
srid wants to merge 6 commits intomasterfrom
dull-type
Draft

fix(code-browser): preserve state across toggle/switch and filter-click (#817, #818)#826
srid wants to merge 6 commits intomasterfrom
dull-type

Conversation

@srid
Copy link
Copy Markdown
Member

@srid srid commented May 4, 2026

Two long-standing irritations in the right-panel Code browser, fixed together. Switching the right panel to Inspector or collapsing it lost the selected file and tree expansion (#818); typing a filter and clicking a result cleared the filter (#817). The fixes are independent but ship as one because the regressions surface in the same component.

Two bugs, two root causes

# Symptom Root cause Fix
#818 Selected file + tree expansion vanish on right-panel toggle / Inspector switch <Show when={!collapsed()}> and match(activeTab()) both unmounted CodeTab, discarding its local state Drop the <Show> (Resizable already shrinks to zero width via sizes=[1,0]) and replace match() with both children rendered + Tailwind hidden toggle
#817 Filter clears when clicking a filtered result Pierre's handleRowClick synchronously calls controller.closeSearch() after firing selection (closeSearch: isSearchOpen is hardcoded in fileTreeRowClickPlan.js with no opt-out) In @kolu/solid-pierre's FileTree wrapper, re-apply props.searchQuery on the next microtask inside onSelectionChange — runs after Pierre's clear, keeps the wrapper push-only

#818 had a tail of its own

Once CodeTab stopped unmounting, three latent issues in CodeTab.tsx surfaced and had to be fixed in this PR:

  • Reset effect re-fired on every preferences tick. on([repoPath, view], …) re-runs its callback whenever any underlying signal emits — and the preferences cell ticks for unrelated updates too. Memoize the projected (repoPath, view) tuple as a string key so the effect fires only on real value transitions.
  • handleSelect mistook Pierre's null for user deselect. Pierre fires onSelectionChange([]) from resetPaths and on tear-down, not just user intent. The Code tab has no UX for explicit deselect (user switches by clicking another file), so ignore null events.
  • Membership check nulled selection during stream resubscribe. Tab switch causes gitStatus / fsListAll to resubscribe (input function returns a fresh object literal each cycle), briefly dropping treePaths() to []. Gate the "selection no longer in tree" check on the relevant stream's pending() accessor — once the stream has delivered, an empty paths set is genuinely authoritative (post-commit, post-rm).

The ordering matters: the reset-effect memo alone fixes the obvious case; the latter two harden CodeTab against transient empty states that the keep-mounted approach now exposes to the membership and selection paths.

Test surface

Three regression scenarios in code-tab.feature:

Plus four new step definitions (right panel tab \"{kind}\", type \"{value}\" into the Code tab filter, the Code tab filter input should contain \"{value}\", and a corrected the right panel should not be visible that asserts effective collapse via bounding-box width — the panel now stays in DOM at ~1px width when collapsed, since a border-l survives sizes=[1,0]).

Try it locally

```sh
nix run github:juspay/kolu/dull-type
```

Closes #817. Closes #818.

Generated by `/do` on Claude Code (model `claude-opus-4-7`).

srid added 4 commits May 4, 2026 19:14
Right panel was unmounting CodeTab via two routes — `<Show
when={!collapsed()}>` in RightPanelLayout and a `match()` swap of
sibling components in RightPanel — both of which discarded CodeTab's
selectedPath signal and Pierre's internal tree expansion on every
collapse or Inspector switch.

Drop the inner `<Show>` (Resizable already shrinks the panel to zero
width via `sizes=[1,0]`, no unmount needed) and replace the `match()`
with both children always rendered, visibility toggled by Tailwind
`hidden`. TAB_KINDS / TAB_LABEL records still give compile-time
exhaustiveness over RightPanelTabKind.

Adds two regression scenarios + the `right panel tab "{kind}"` step.
…817)

Pierre's row-click handler synchronously calls `controller.closeSearch()`
after firing selection (verified at @pierre/trees/dist/render/
FileTreeView.js around the row-click plan, where `closeSearch:
isSearchOpen` is hardcoded with no opt-out). When the host drives search
externally via the wrapper's `searchQuery` prop, every click would clear
Pierre's internal filter while the host's signal still held the query —
input shows "alp", tree shows everything.

Re-apply the host's query on the next microtask inside `onSelectionChange`,
after Pierre's clear has run. Keeps the wrapper push-only (no need to
read Pierre's internal search state). Assumes Pierre's row handler stays
synchronous.

Adds a regression scenario plus the `type into filter` and
`filter input should contain` step definitions.
Surfaced when CodeTab stopped unmounting on right-panel tab toggle: the
reset effect at CodeTab.tsx (`on([repoPath, view], ...)`) was firing on
every \`preferences\` cell tick, not just on actual repo/view changes.
SolidJS \`on(...)\` runs the callback whenever the underlying signals
emit — without an equality gate on the projected value, an unrelated
preference update (or a snapshot replay) re-ran the callback and the
unconditional \`setSelectedPath(null)\` wiped the user's selection on
every tick. Pre-fix this was masked because tab-switch tore CodeTab down
entirely; once mounted state survives toggles, the effect leaked.

Memoize the (repoPath, view) tuple as a string key so the reset effect
fires only on real value transitions. Also harden two related sites:

- \`handleSelect\` ignores \`null\` from Pierre — Pierre fires
  \`onSelectionChange([])\` from \`resetPaths\` and tear-down, not just
  user deselect, and the Code tab has no UX affordance for explicit
  deselect (user switches selection by clicking another file).
- The membership-check effect treats an empty \`treePaths\` as "tree not
  yet loaded" rather than "selected file is missing", so a stream
  resubscribe (which briefly drops \`status()\` to undefined) doesn't
  null \`selectedPath\`.

Update the \`right panel should not be visible\` step to assert effective
collapse via bounding-box width — after #818 the panel stays in the DOM
at ~0 width when collapsed (a 1px \`border-l\` is all that remains, so
Playwright's \`state: "hidden"\` would still see it as visible).
Followup to the previous commit. The earlier guard treated any empty
\`treePaths\` as "tree loading" — but two existing scenarios assert that
a truly-empty tree (post-commit clearing local changes; post-rm
deleting the browsed file) DOES clear the stale \`selectedPath\` and
shows the "Select a file…" hint.

Distinguish loading from genuinely empty by checking the relevant
stream's \`pending()\` accessor: the gitStatus / fsListAll subscription
exposes whether it is mid-resubscribe. If pending, hold the selection
through the transient empty; once the stream has delivered (\`!pending\`)
an empty paths set is authoritative and we null normally.
@srid srid mentioned this pull request May 4, 2026
@srid
Copy link
Copy Markdown
Member Author

srid commented May 4, 2026

/do results

Step Status Duration Verification
sync 1s git fetch ok; forge=github
research 13s Talk-mode plan: drop <Show> at RightPanelLayout.tsx:69; replace match() at RightPanel.tsx:81-90 with hidden-toggle divs (#818); queueMicrotask re-apply in solid-pierre FileTree onSelectionChange after row click (#817). Pierre source verified at FileTreeView.js:1738 + fileTreeRowClickPlan.js:11.
branch 11s On dull-type, 0 ahead of origin/master
implement 2m 8s Both fixes + 3 regression scenarios + 3 step defs
check 54s just check clean; 48 pre-existing warnings, none in changed files
fmt 9s just fmt clean — 0 files reformatted
commit 2m 10s Two commits per the plan: a9544488 (#818), 488d8665 (#817)
test 33m 54s 244/244 e2e pass after 4 followup commits (visibility-step, memo gate, handleSelect ignore-null, membership-check pending-gate)
create-pr 1m 3s Draft PR #826
ci 7m 45s 14/15 contexts pass; e2e@aarch64-darwin flakes scattered across 3 retries — different scenarios each time, all unrelated to right-panel/code-tab. Filed at #320.
Total 48m 41s

Slowest step: test (33m 54s — three full e2e runs to debug the cascading #818 followups, plus the final clean run)

Optimization suggestions

  • Talk-mode reviewers should run on a draft diff, not just a sketch. The four Code browser doesn't remember state #818 followup commits all surfaced as test failures, not as design-phase findings. A post-implement hickey/lowy pass before the first push would have caught the unmemoed on([repoPath, view], …) cascading on every preference tick.
  • The defer: true + reactive-on-array pattern is a recurring footgun. on([a, b], …) re-fires on every upstream signal emit regardless of value change — three CodeTab effects had this shape and only one needed fixing this round, but the others (and any new ones) carry latent risk. A repo-wide Grep for on(\[ followed by an audit could be a fast standalone PR.
  • e2e suite dominates wall time at 34m for one feature change. A --feature code-tab.feature filter on just test-quick would have made the failure-iteration loop ~3× faster; the bottleneck was running the full 244-scenario suite each retry. Worth a just test-quick-feature <name> recipe.

Workflow completed at 2026-05-04 (UTC).

srid added 2 commits May 4, 2026 21:20
Swaps the #817 fix from `onSelectionChange` to a DOM `click` listener on
the FileTree wrapper's container, matching @srid's parallel PR #824. The
original `onSelectionChange` hook missed the re-click case: Pierre's
`#applySelection` short-circuits its `selectionVersion` increment when
the new selection equals the current one, so the callback never fires
on re-click of an already-selected row — but `controller.closeSearch()`
still runs on every row click, so the filter would be wiped with no
recovery path. The DOM-event hook fires regardless of selection-version
state.

Three calls to Pierre's `setSearch` now route through a single
`applySearchQuery` helper that owns normalization (`""` → `null`,
Pierre's contract for "no filter") and `onError` routing.

Also folds in reviewer feedback (hickey + lowy on the post-implement
diff):

- `aria-hidden` on the right-panel root (collapsed) and on each tab
  wrapper (inactive) — makes the keep-mounted contract legible to both
  future readers and assistive tech.
- Top-of-block invariant comment in CodeTab naming the
  selection-stability invariant the three guards collectively defend
  ("preserve selection across non-genuine transitions"); each guard
  documents a different churn source.
- `resetKey` separator note: `::` is collision-safe given current
  `view()` enum + `repoPath()` semantics.
- Strengthened comment on the row-click handler explicitly naming the
  two Pierre invariants the microtask-after-click pattern relies on
  (synchronous handler; `data-item-path` attribute on rows).
- Re-click assertion added to `Filter survives clicking a filtered
  result` — the case the previous implementation missed.

Approach credit: PR #824 by @srid (Codex). The DOM-listener structure,
`applySearchQuery` helper, and re-click test scenario are adapted from
that PR.
@srid
Copy link
Copy Markdown
Member Author

srid commented May 5, 2026

Hickey/Lowy Analysis

Post-implement review pass on the diff (originally skipped under --minimal). Both reviewers were also asked to compare against the parallel PR #824, which targets #817 only with a different approach.

# Lens Finding Disposition
1 Hickey onSelectionChange-based #817 fix structurally misses re-click — Pierre's #applySelection short-circuits the selectionVersion bump when re-selecting the same row, so the callback never fires, but closeSearch() still runs Fixed in this PR (adopted #824's DOM click listener)
2 Lowy DOM hook earns its seam: closeSearch fires on every click regardless of selection state, so the right invariant is "after any user click, restore host-owned search" — not "after selection change" Fixed in this PR (same as #1)
3 Hickey Three setSearch call sites duplicate normalize + try/catch — passes dry-rule-of-three Fixed in this PR (applySearchQuery / normalizeSearchQuery helpers, adopted from #824)
4 Lowy Pierre synchronicity assumption needs to be in the comment as an invariant, not a description of what happens today Fixed in this PR (comment names both invariants explicitly: synchronous row-click handler + data-item-path attribute)
5 Lowy Keep-mounted contract is implicit in RightPanel — future refactor back to conditional rendering would silently violate CodeTab's three guards Fixed in this PR (aria-hidden on panel root + per-tab wrapper makes the contract legible)
6 Hickey Three selectedPath-clearing guards are correctly separated by churn source, but no named concept ties them together Fixed in this PR (top-of-block invariant comment in CodeTab)
7 Hickey resetKey separator collision risk — :: is fine given current view() enum and repoPath() semantics, but worth a comment Fixed in this PR
8 Lowy Extract createStableSignal if a fourth selection guard surfaces Defer — file as follow-up if a fourth churn source appears
9 Lowy Extract createRealValue(signal, pending) if a second effect needs the pending-gate pattern Defer — only one effect needs it today
10 Hickey + Lowy Both PRs work around Pierre's unconditional closeSearch() after row clicks — the structurally correct fix is an upstream Pierre API for opting out Defer — file Pierre upstream issue separately; out of scope here

Hickey rationale

PR #826's onSelectionChange hook is a worse seam for this problem: it is semantically correct only when selection changes, making it structurally unable to express "maintain search invariant regardless of whether selection changes." That is not a gap to patch — it is the wrong concept entirely. PR #824's DOM click listener addresses the actual invariant.

On CodeTab's three guards: "They are not the same invariant from different angles; they are three distinct noise sources arriving via three different channels (upstream signal ticking, stream lifecycle, Pierre API semantics). Collapsing them into one would couple those channels in a single place, which is worse than the current distribution. The distribution is correct here. What makes them feel fragmented is that none of the three individual locations states the governing invariant."

On display:none + Pierre: confirmed safe — Pierre does not use ResizeObserver (verified across @pierre/trees/dist/). Uses explicit virtual-list layout, not container observation.

Lowy rationale

PR #824 puts the workaround at the click-event level (DOM listener inspecting composedPath()). My PR puts it at the onSelectionChange level (Pierre's API callback). Both are coupling points, but only one — DOM event level — covers the full set of triggers. Public-callback coupling looks more stable until you discover the API is structurally silent on the case you care about.

Structural observation worth recording: keep-mounted is a pattern in this codebase, not a one-off — every reactive component that maintains subscriptions across visibility changes will face the same pressure (guards against churn, pending gates, null filters). Worth naming in architecture notes for future component design.

Deeper structural gap neither PR closes: "Both PRs are host-level patches for Pierre's unconditional closeSearch() call. The stable decomposition would place a single 'Pierre search lifecycle' module between the host and Pierre, owning the re-apply contract in one place regardless of which Pierre event fires. Neither PR proposes that; both inline the workaround. If Pierre adds more events that clear search (keyboard nav, context menu, etc.), each becomes a new patch site."

Comparison with #824

PR #824 ships the same #817 fix (which we've now adopted) but doesn't address #818 (state loss on panel toggle / Inspector switch). This PR carries both fixes plus the three #818 follow-ups (memoized reset key, ignore-null in handleSelect, pending-gated membership check). Approach credit for the row-click hook structure, helper extraction, and re-click test scenario goes to @srid via #824.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Code browser doesn't remember state Clicking resets search results in Code browser

1 participant