Skip to content

Repaint every row sharing a toggled profile's name, not just one - #28

Merged
SwatX18 merged 1 commit into
masterfrom
work/duplicate-name-refresh
Aug 31, 2026
Merged

Repaint every row sharing a toggled profile's name, not just one#28
SwatX18 merged 1 commit into
masterfrom
work/duplicate-name-refresh

Conversation

@SwatX18

@SwatX18 SwatX18 commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Profile suppression (the toggle hotkey from #14) is keyed by ApplicationSetting.Name. The list repaint was keyed by FileName.

RefreshToggledListItemAppearance resolved a single setting through FindMatch, then repainted the one row whose Tag held that setting's path. So when two profiles shared a Name, one hotkey press suppressed both and only one row changed appearance — the other kept advertising a state it no longer had.

The collision is trivially reachable

This wasn't obvious when the item was deferred, and it's the reason to fix it rather than leave it.

Name is Path.GetFileNameWithoutExtension(_filePath) — nothing more. The manual Add Program flow dedupes only on the full FileName. So adding D:\SteamLibrary\GameA\game.exe and D:\GOG\GameB\game.exe through the ordinary UI produces two profiles both named game, with no warning.

The game-finder's bulk-add path is the one place that already guards against a Name collision — which is likely why this survived unnoticed. The gap only opens on manual adds.

The fix

The decision is now a pure static, FindApplicationSettingsByName, that the real refresh path calls — the same shape as DescribeListItem and ShouldRefreshListItemForToggleResult, and for the same reason: a fixture cannot construct a Form, so the testable part has to be the decision rather than the painting.

FindApplicationListItem is unchanged and needed no sibling. Its other caller (OnForegroundChangedConfirmExecutable) legitimately wants exactly one row, since the unconfirmed-flag clear is per-FileName.

The two comparisons are now one comparison

StringComparer.OrdinalIgnoreCase moved out of the suppression HashSet into ProfileToggleHelper.NameComparer, and the new lookup compares through that same field rather than restating the intent.

That this matters was proved, not assumed: flipping NameComparer to Ordinal fails a pre-existing cross-case suppression check and the new O4 simultaneously. The two are structurally one comparison now, and any future edit is caught from both directions.

Testing

424 checks, up seven (O1O7). Each mutation-proved individually — including reproducing the original defect exactly (return after the first match), which fails O2 alone.

One gap stays open, and it was found by testing for it

Reverting the call site to repaint a single row, while leaving the pure function intact, still passes all 424 checks. The loop, the FindApplicationListItem lookup and ApplyApplicationListItemAppearance all touch a real ListView on a real Form that no fixture here can build.

That wiring is verified by direct code reading — lvi.Tag and FileName are the same key on both sides — not by a check. It is the same boundary ApplyApplicationListItemAppearance has always had, stated rather than assumed.

Profile suppression is keyed by ApplicationSetting.Name; the list
repaint was keyed by FileName. RefreshToggledListItemAppearance
resolved a single setting through FindMatch and repainted the one row
whose Tag held that setting's path. So when two profiles shared a Name,
one hotkey press suppressed both and only one row changed appearance --
the other kept advertising a state it no longer had.

The collision is trivially reachable, which was not obvious when this
was deferred. Name is Path.GetFileNameWithoutExtension of the
executable path, nothing more, and the manual Add Program path dedupes
only on the full FileName. Adding D:\SteamLibrary\GameA\game.exe and
D:\GOG\GameB\game.exe through the ordinary UI produces two profiles
both named "game", with no warning. The game-finder's bulk path is the
one place that already guards against this, which is likely why the gap
survived: it only opens on manual adds.

The decision is now a pure static, FindApplicationSettingsByName, that
the real refresh path calls -- the same shape as DescribeListItem and
ShouldRefreshListItemForToggleResult, and for the same reason: a
fixture cannot construct a Form, so the testable part has to be the
decision rather than the painting.

The two comparisons are now one comparison. StringComparer.
OrdinalIgnoreCase moved out of the suppression HashSet into
ProfileToggleHelper.NameComparer, and the lookup compares through that
same field instead of restating the intent. Flipping it to Ordinal now
fails a pre-existing suppression check and a new one together, from
both directions -- which is the property that stops these two drifting
apart again.

Seven checks, 417 to 424.

One gap stays open and is worth stating. Reverting the call site to
repaint a single row, while leaving the pure function intact, still
passes all 424 checks: the loop, the FindApplicationListItem lookup and
ApplyApplicationListItemAppearance all touch a real ListView on a real
Form, which no fixture here can build. That wiring is verified by
reading -- lvi.Tag and FileName are the same key on both sides -- not by
a check. It is the same boundary ApplyApplicationListItemAppearance has
always had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187tGqyEw4frZzDYPPJfUMd
@SwatX18
SwatX18 merged commit 9a3dbed into master Aug 31, 2026
@SwatX18
SwatX18 deleted the work/duplicate-name-refresh branch August 31, 2026 13:22
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