Skip to content

fix(quickfiler): correct keyboard-action registration desync in KbdActions, collection navigation and item expansion (#444, #472, #482) - #654

Merged
drmoisan merged 17 commits into
epic/quickfiler-bug-family-integrationfrom
bug/quickfiler-keyboard-action-defects-444
Aug 27, 2026
Merged

fix(quickfiler): correct keyboard-action registration desync in KbdActions, collection navigation and item expansion (#444, #472, #482)#654
drmoisan merged 17 commits into
epic/quickfiler-bug-family-integrationfrom
bug/quickfiler-keyboard-action-defects-444

Conversation

@drmoisan

Copy link
Copy Markdown
Owner

Summary

Fixes three keyboard-action registration defects in QuickFiler. All three share one root cause: a registration and an unregistration disagreeing about what was registered, with the disagreement swallowed because every production call site discards the bool that KbdActions.Remove returns.

Issue Defect Fix
#444 KbdActions(IEnumerable<UClass>) bypassed the duplicate-registration guard that Add enforces, so a seed sequence containing two StoredKeyEquals-equal keys constructed silently and threw later. The enumerable constructor now applies the same guard, logs via the existing logger.Error, and throws ArgumentException.
#472 UnregisterNavigation re-read the live Digits width instead of the width in force at registration, so growing or shrinking the item count between register and unregister removed keys the page never registered and left the real ones behind. QfcCollectionController records the registration width in a new private int _registeredDigits field and UnregisterNavigation replays that recorded width, with zero reads of the Digits property.
#482 ToggleExpansion and ToggleExpansionAsync each maintained a different keyboard registry, so a synchronous toggle cleared CharActions (where nothing had been added) while CharActionsAsync kept its entries, and the next expansion threw ArgumentException from Add. Both overloads now delegate expansion registration to a single owner, private void SyncExpandedRegistrations(bool expanded), which maintains both registries together.

Base branch is epic/quickfiler-bug-family-integration (epic quickfiler-bug-family), not main.

Deliberate behaviour widening

The #482 fix routes all expansion keyboard registration through SyncExpandedRegistrations(bool expanded), which maintains both the synchronous _kbdHandler.CharActions registry and the asynchronous _kbdHandler.CharActionsAsync registry together rather than one per toggle path. That is a deliberate widening of observable behaviour, not an incidental side effect:

  • 'B' and 'D' now respond after a synchronous expansion. Previously only an asynchronous expansion populated CharActionsAsync, and the Alt-key path that reads CharActions was left empty after a synchronous toggle.
  • Alt+B and Alt+D now respond after an asynchronous expansion. Previously only a synchronous expansion populated CharActions.

The alternative — collapsing onto a single registry — was considered and rejected. Four focus-path methods in QfcItemController.EventWiring.cs conditionally call the expansion register and unregister methods on _expanded. Under a single-registry unification one of those cleanup paths would remove from the registry that no longer holds the entries, re-creating exactly the silent-false divergence these three issues describe. Maintaining both registries makes every one of those four call sites operate on a registry that genuinely holds the entries.

Idempotence is preserved because the two unregister calls are unconditional and KbdActions.Remove returns false rather than throwing when the pair is absent. That is why repeated and interleaved toggles no longer raise ArgumentException from KbdActions.Add, without any change to Add's contract.

Correction to #482's filed trigger and severity

The filed issue's stated trigger is unreachable and its stated severity is overstated. Both corrections are recorded in spec.md under ### #482 — expansion registry divergence and are repeated here so this PR does not restate an unsupported claim.

The filed trigger is dead code with respect to this interleaving. The promoted document names the synchronous ToggleExpansion() call inside ActivateBySelectionAsync as what makes the interleaving reachable in production rather than theoretical. That call is guarded by if (blExpanded), and both asynchronous callers pass a value that is always false: one passes the literal false, and the other passes a value returned from ToggleOffActiveItemAsync, whose expansion branch is commented out so it returns its parameter unchanged. The guarded call therefore never executes with a true argument, and the filed trigger cannot produce the interleaving.

The live trigger is Right, then Down, then Right:

  1. Right on an item runs ToggleExpansionAsync(On), setting _expanded = true and adding 'B' and 'D' to CharActionsAsync.
  2. Down runs SelectNextItemAsync, which marshals to the synchronous SelectNextItem, and through ChangeByIndex and ToggleOffActiveItem reaches the synchronous ToggleExpansion(). That clears _expanded and removes from CharActions, where nothing was ever added, so Remove returns false silently. CharActionsAsync still holds 'B' and 'D'.
  3. Right on the same item again finds _expanded == false, so ToggleExpansionAsync(On) runs and CharActionsAsync.Add is called for an entry that is already present, raising ArgumentException.

The severity is a dead key, not a crash. The exception surfaces through the asynchronous keyboard handler in KeyboardHandler.cs, whose catch block logs it. The user-visible symptom is a 'B' or 'D' key that stops responding for that item — not an unhandled exception and not a crash.

Known follow-up: issue #644

spec.md ### Downstream notes item 3 describes a second, distinct defect in UnregisterNavigation, which this PR deliberately leaves in place: the method bounds its unregister loop with the current _itemGroups.Count, while RemoveSpecificControlGroup(int) mutates _itemGroups with no unregister/register bracket. When a group is removed through that unbracketed path — reachable from RemoveBelowThresholdAsync via the RemoveGroupByEntryId seam, and from the 'R' char action — the count the unregister loop later reads no longer matches the count in force at registration, so the loop stops short and leaves orphaned navigation registrations behind.

That defect is tracked as issue #644 (qfc-unregister-navigation-count-mismatch-orphan, #644), promoted through the feature-promotion lifecycle in commit 12256da4 with potential entry docs/features/potential/promoted/2026-08-27-qfc-unregister-navigation-count-mismatch-orphan.md.

It is out of scope here under CLAUDE.md's Bugfix Workflow step 2 ("If you uncover deeper design problems, open a new issue instead of widening scope"): the fix requires a key-ledger redesign that breaks characterization tests in QfcCollectionControllerTests.cs, a file sitting at exactly 500 lines with a [TestMethod] count frozen by upstream #468. This PR's #472 regression test asserts the residual orphan explicitly — exactly one "10" entry remains — and carries an XML doc comment attributing that residual to issue #644, so the assertion does not silently absorb the second defect.

Changes

Three production files, four test files, one project-file line.

  • QuickFiler/Controllers/KbdActions.cs (+36)
  • QuickFiler/Controllers/QfcCollectionController.cs (+8 −8)
  • QuickFiler/Controllers/QfcItemController.Navigation.cs (+28 −4)
  • QuickFiler.Test/Controllers/QfcCollectionControllerNavigationDigitsTests.cs (new, +226)
  • QuickFiler.Test/Controllers/QfcItemController.NavigationTests.cs (+107)
  • QuickFiler.Test/Controllers/KbdActionsRemainingBranchesTests.cs (+91)
  • QuickFiler.Test/Controllers/KbdActionsTests.cs (+37)
  • QuickFiler.Test/QuickFiler.Test.csproj (+1, one <Compile Include> in the Controllers\Qfc* slot)

No public API was added, removed, or re-signed; the two added members are both private. QuickFiler/QuickFiler.csproj is untouched. KeyboardHandler.cs, IQfcCollectionController.cs and the nine other QfcItemController partials are unmodified.

Verification

Full C# toolchain, re-run in the mandated order after merging integration tip 13a22ade. All four gates pass in a single loop; no gate rewrote a file, so no restart was triggered.

Gate Command Result
Format dotnet tool run csharpier check . exit 0 — 1543 files, 0 unformatted
Analyzers msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true exit 0 — 0 errors, 5 warnings
Nullable msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true exit 0 — 0 errors
Tests pwsh -NoProfile -File ./scripts/vscode/Invoke-MSTestWithCoverage.ps1 exit 0 — 6719 / 6719 passed

/t:Rebuild was used for both msbuild gates. Non-vacuity is proven by a zero count of Skipping target "CoreCompile" together with 15 and 11 executed CoreCompile: targets respectively — not by a csc-invocation count, which reads 0 at this verbosity even on a real compile. /p:Nullable=enable was not added to any command.

All five analyzer warnings are the pre-existing System.Reactive 7.0.0 packages.config diagnostic, one per consuming project, identical to the Phase 0 baseline.

Coverage

Unfiltered whole-run denominator, the same wrapper and denominator as the Phase 0 baseline.

Figure Baseline Final
Repository-wide line coverage 85.04% 85.1326%
Repository-wide branch coverage 79.12% 79.2162%
KbdActions.cs line-rate 0.93976 0.98980
QfcItemController.Navigation.cs line-rate 0.90678 0.92126
SyncExpandedRegistrations (new member) n/a 100%

Both figures moved up and clear both readings of the repository's two coverage policies — the CLAUDE.md §UT2 floor of >= 80% line, and the .claude/rules/general-unit-test.md / quality-tiers.md floors of >= 85% line and >= 75% branch. No interpretation was needed to declare a pass. That two-policy conflict is pre-existing and is recorded as unresolved rather than silently decided.

Regression tests

Each of the three fixes has a fail-before / pass-after record under evidence/regression-testing/ and evidence/qa-gates/. The guard's throw path and its normal-completion path are both covered.

Acceptance criteria

spec.md is the sole acceptance-criteria source (work mode full-bug; user-story.md is intentionally absent). 57 of 57 criteria are checked. Feature review returned 0 Blocking findings across policy-audit, code-review and feature-audit.

Notes for the epic capstone

Closes #444
Closes #472
Closes #482

🤖 Generated with Claude Code

https://claude.ai/code/session_01T45irz1DRk6bPnxx5ifqFA

drmoisan and others added 17 commits August 27, 2026 09:45
Acceptance criterion AC-472-10 of the quickfiler-keyboard-action-defects
spec requires the unbracketed-removal count-mismatch defect recorded in
`### Downstream notes` item 3 to be promoted into a new potential entry
AND a new GitHub issue, on the stated grounds that prose left in the
feature folder does not survive that folder's archival.

Promoted through the MCP lifecycle (bug route, full-bug mode) as issue
#644, verified OPEN with its full ten-section body intact. The defect
itself is NOT fixed here: it needs the key-ledger design, which changes
the outcome of characterisation tests in a 500-line file whose
[TestMethod] count issue #468 froze.

Also corrects the agent-memory entry on promotion body handling. The
promotion tool maps sections by heading NAME against the issue template:
template headings survive with their content intact, and only
non-template headings are dropped. The previous entry generalised a real
observation into a wrong causal rule that predicted data loss where
there is none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T45irz1DRk6bPnxx5ifqFA
Phase 4 final QA loop evidence: repository-wide csharpier check (0 unformatted
of 1541 files), post-format size audit, analyzer Rebuild (0 errors, 5
pre-existing warnings, 0 skipped CoreCompile), type-check Rebuild without
/p:Nullable=enable (0 errors, 0 skipped CoreCompile), full-suite vstest run
(6713 of 6713 passed), TRX host-value normalization, final coverage capture
(line 85.13 percent, branch 79.21 percent), SyncExpandedRegistrations line-rate
1, the KbdActions guard covered on both paths, the coverage delta report
(line +0.09, branch +0.09) and the clean-pass aggregator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T45irz1DRk6bPnxx5ifqFA
Checks off AC-QA-01 through AC-QA-06, AC-QA-08, AC-QA-09, AC-QA-10 and the
Phase 3 deferral AC-482-08 in spec.md, each against the Phase 4 evidence:
size audit, csharpier check, analyzer Rebuild, type-check Rebuild, the
6713-of-6713 vstest run, SyncExpandedRegistrations line-rate 1, the
KbdActions guard covered on the throwing and non-throwing paths, and the
non-negative coverage deltas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T45irz1DRk6bPnxx5ifqFA
…ions

Phase 5 scope-discipline gates and acceptance-criteria reconciliation:
branch file list against the re-derived merge base (three production paths,
one project file), forbidden-path gate (eleven of eleven absent), upstream
contract conformance (six of six rows MATCH), Remove contract (1 and 0),
public-API gate (zero public added/removed/re-signed), declined timer seam
(zero added Timer lines), the no-live-form guard test, the plan
line-number-citation gate (both counts zero), PR-body inputs, the completion
report, and the 57-row AC reconciliation.

Checks off AC-SCOPE-01 through AC-SCOPE-11, AC-QA-07, AC-QA-11, AC-QA-12 and
AC-QA-13 in spec.md, bringing the total to 54 of 57. The three remaining
criteria (AC-472-10, AC-482-11, AC-482-12) each require the integration
pull-request body and are recorded as deferred under evidence/issue-updates/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T45irz1DRk6bPnxx5ifqFA
Records the terminal git status under the feature pathspec (QuickFiler,
QuickFiler.Test, and the feature folder) and closes out the plan checklist:
all 167 tasks checked. Zero source, test, or project paths uncommitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T45irz1DRk6bPnxx5ifqFA
…acts

All three audits returned 0 Blocking findings. Non-blocking items are the
pre-existing 2437-line QfcCollectionController.cs (unchanged in size by this
branch), the pre-existing 80-vs-85 percent coverage-floor documentation
conflict, and five code-review observations none of which are regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T45irz1DRk6bPnxx5ifqFA
… criteria

Re-ran the full C# toolchain in the mandated order after merging integration
tip 13a22ad (#493 fan-in). All four gates pass in a single loop: csharpier
check 1543 files 0 unformatted; analyzer Rebuild 0 errors with a zero
Skipping-target-CoreCompile count and 15 executed CoreCompile targets;
nullable Rebuild 0 errors with 11 executed CoreCompile targets; 6719 of 6719
tests pass. Line coverage 85.1326 percent, branch 79.2162 percent.

Checked off the final three acceptance criteria, which were deferred pending
the PR body and are now genuinely satisfied by artifacts/pr_body_444.md: the
issue #644 follow-up number, the deliberate behaviour widening, and the
corrected #482 trigger and severity. spec.md is now 57 of 57.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T45irz1DRk6bPnxx5ifqFA
@drmoisan
drmoisan merged commit 69e8317 into epic/quickfiler-bug-family-integration Aug 27, 2026
5 checks passed
@drmoisan
drmoisan deleted the bug/quickfiler-keyboard-action-defects-444 branch August 28, 2026 11:56
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.

1 participant