Skip to content

fix(settings): prevent duplicate repo row id when a repo is both pinned and hidden#73

Merged
steipete merged 4 commits into
steipete:mainfrom
devYRPauli:fix/pinned-hidden-duplicate-row-id
Jun 5, 2026
Merged

fix(settings): prevent duplicate repo row id when a repo is both pinned and hidden#73
steipete merged 4 commits into
steipete:mainfrom
devYRPauli:fix/pinned-hidden-duplicate-row-id

Conversation

@devYRPauli

@devYRPauli devYRPauli commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Prevent the Settings repository table from emitting two rows with the same id when a repo is in both the pinned and hidden lists.

Problem

A repository that ends up in both the pinned and hidden lists produces two settings-table rows that share the same Identifiable id. RepoSettingsView renders Table(filteredRows, selection:) keyed on RepoBrowserRow.id, and duplicate ids are undefined behavior in SwiftUI Table (broken rendering and selection).

Root cause

Two cooperating issues:

  1. RepoBrowserRows.make computes loadedKeys only from loaded repositories, then appends a manual row for every pinned name not loaded and, separately, for every hidden name not loaded. The hidden loop never checks against rows the pinned loop already appended, so a name present in both lists (and not loaded) is appended twice with id = normalized(fullName).
  2. addPinned appends to the pinned list but does not remove the repo from the hidden list, while its mirror hide() already removes from pinned. So hide(X) then pin(X) leaves X in both lists, which is what triggers the double append.

Fix

  1. RepoBrowserRows.make now tracks every already-emitted key (loaded rows plus any manual rows it appends) in a seenKeys set and skips duplicates, so it can never emit two rows with the same id regardless of how the lists were populated. Pinned is processed first, so a name in both lists wins as pinned, which matches the user's explicit intent when they pin a repo.
  2. addPinned now removes the repo from hiddenRepositories, mirroring hide(), so pin and hide stay mutually exclusive. It is also idempotent: the early-return for an already-pinned repo no longer skips the hidden cleanup, so re-pinning a repo that is already pinned but still hidden repairs that stale state instead of leaving it inconsistent.

Relation to #32

Distinct code path. #32 handled crashes from duplicate loaded repos (Dictionary(uniqueKeysWithValues:) in AppState+Refresh and NSMenu dedup in StatusBarMenuBuilder). This fix is about the Settings Table's manual pinned/hidden rows and the addPinned/hide asymmetry, and touches neither of those files.

Tests

  • make() with no loaded rows and the same name in pinned and hidden yields exactly one row, all ids unique (Set(ids).count == ids.count), visibility .pinned.
  • addPinned("owner/X") when X is hidden removes X from hidden and keeps it pinned.
  • addPinned("owner/X") when X is already pinned and still hidden removes X from hidden without adding a duplicate pin (regression for the early-return gap).

Proof (real swift test output)

The buggy state is reproduced and fixed against the actual RepoBar source via the repo's own RepoBarTests target (it builds and links headless).

Before (committed code), the new regression test fails on the early-return gap:

✘ Test "addPinned repairs an already-pinned repo that is still hidden"
  Expectation failed: !(hidden.contains { ... "owner/X" ... })

After (this branch):

✔ Test "make emits one unique row when a repo is both pinned and hidden" passed
✔ Test "addPinned removes the repo from the hidden list" passed
✔ Test "addPinned repairs an already-pinned repo that is still hidden" passed
✔ Test run with 12 tests in 2 suites passed

The make() test directly verifies the invariant SwiftUI Table needs: before the fix it returned 2 rows with the duplicate id owner/x; after, it returns a single ["owner/x"] row. Full suite: 562 tests in 98 suites pass; swiftformat --lint and swiftlint --strict clean on the changed files.

Runtime proof (running app)

The fix exercised in the actual running app, not just the unit test. A debug .app build (the repo's own Scripts/compile_and_run.sh) was launched, and on launch the production RepoBrowserRows.make path that RepoSettingsView feeds into the Settings Table (RepoSettingsView.swift:205) was run with a repo present in both the pinned and hidden lists.

Before (pre-fix make), the live app emits two rows sharing the same id — the duplicate Identifiable.id the SwiftUI Table chokes on:

[#73 PROOF] running RepoBar app -> RepoBrowserRows.make with 'owner/demo' in BOTH pinned+hidden => total rows=2; rows with id 'owner/demo'=2; all ids=["owner/demo", "owner/demo"]; demo visibility=["pinned", "hidden"]

After (this branch), the same state collapses to a single, uniquely-identified .pinned row:

[#73 PROOF] running RepoBar app -> RepoBrowserRows.make with 'owner/demo' in BOTH pinned+hidden => total rows=1; rows with id 'owner/demo'=1; all ids=["owner/demo"]; demo visibility=["pinned"]

Captured at runtime from the live app process on macOS.

For a pixel-level screenshot of the rendered Settings table, a maintainer can still invoke Mantis on this PR.

Found and fixed with the help of the Claude CLI.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@clawsweeper

clawsweeper Bot commented Jun 2, 2026

Copy link
Copy Markdown

Codex review: needs maintainer review before merge. Reviewed June 5, 2026, 5:19 PM ET / 21:19 UTC.

Summary
The branch makes pinned repositories win over stale hidden entries in settings rows and menu filtering, adds a RepoListSettings pin helper, and covers the cases with Swift tests plus an unreleased changelog entry.

Reproducibility: yes. from source inspection: current main appends pinned and hidden manual rows against the same loaded-only key set, so an unloaded repo in both lists produces duplicate row ids. The PR body also includes before/after live app output for the same production path.

Review metrics: 2 noteworthy metrics.

  • Changed files: 7 files: 3 production, 3 test, 1 changelog. The production behavior change is paired with targeted regression coverage and a release-note entry.
  • Regression tests: 7 added. The added tests cover manual settings rows, loaded settings rows, pin state repair, visible selection, and shared pipeline filtering.

Merge readiness
Overall: 🦞 diamond lobster
Proof: 🦞 diamond lobster
Patch quality: 🦞 diamond lobster
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Risk before merge

  • [P1] I did not run pnpm test or pnpm check because this review was read-only; the PR body reports the focused tests, full suite, swiftformat lint, and swiftlint strict as passing.

Maintainer options:

  1. Decide the mitigation before merge
    Land this focused fix after normal checks so pinned/hidden settings stay mutually exclusive and stale inconsistent settings render with unique table ids.
  2. Pause or close
    Do not merge this PR until maintainers decide whether the risk is worth taking.

Next step before merge

  • No automated repair lane is needed; the next action is normal maintainer review and merge after required checks.

Security
Cleared: The diff touches Swift settings/filtering code, tests, and a changelog only; no concrete security or supply-chain concern was found.

Review details

Best possible solution:

Land this focused fix after normal checks so pinned/hidden settings stay mutually exclusive and stale inconsistent settings render with unique table ids.

Do we have a high-confidence way to reproduce the issue?

Yes from source inspection: current main appends pinned and hidden manual rows against the same loaded-only key set, so an unloaded repo in both lists produces duplicate row ids. The PR body also includes before/after live app output for the same production path.

Is this the best way to solve the issue?

Yes, this is a narrow maintainable fix because it repairs both the persisted state mutation and the row/filter consumers that must tolerate stale settings. The added tests cover the main stale-state variants without adding a new user-facing mode.

AGENTS.md: found and applied where relevant.

Codex review notes: model gpt-5.5, reasoning high; reviewed against 030a49f0ec99.

Label changes

Label justifications:

  • P2: The PR fixes a normal settings/menu visibility bug with limited blast radius and clear regression coverage.
  • rating: 🦞 diamond lobster: Overall readiness is 🦞 diamond lobster; proof is 🦞 diamond lobster and patch quality is 🦞 diamond lobster.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR body includes after-fix live app output plus before/after test output for the duplicate settings-row path.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes after-fix live app output plus before/after test output for the duplicate settings-row path.
Evidence reviewed

What I checked:

  • Current-main duplicate row path: RepoBrowserRows.make builds loadedKeys only from loaded rows, then separately appends pinned and hidden manual rows; an unloaded repository present in both lists can therefore emit two rows with the same normalized id. (Sources/RepoBar/Settings/RepoBrowserRows.swift:115, 030a49f0ec99)
  • Settings table consumes those ids: RepoSettingsView renders Table(filteredRows, selection:) from RepoBrowserRows.make, so duplicate RepoBrowserRow.id values are on the SwiftUI Table path described by the PR. (Sources/RepoBar/Settings/RepoSettingsView.swift:57, 030a49f0ec99)
  • Current-main pin/hide asymmetry: addPinned currently only appends to pinnedRepositories and returns early if already pinned, while hide removes the same normalized repo from pinnedRepositories, leaving a stale hidden entry possible when pinning after hide or repairing old settings. (Sources/RepoBar/App/AppState+Visibility.swift:71, 030a49f0ec99)
  • Patch scope: The PR patch updates RepoBrowserRows, addPinned/RepoListSettings pin handling, shared RepositoryPipeline filtering, and regression tests for manual rows, loaded rows, settings selection, and menu pipeline filtering. (7962ecf17f76)
  • Related item is not a superseding fix: The provided related pull request at fix: handle duplicate repository keys in Dictionary and NSMenu #32 is closed unmerged and addressed duplicate loaded repository/dictionary/menu crash paths, not this settings manual-row id path.
  • Area history: git blame ties the current settings and pipeline behavior to the v0.7.0 main snapshot authored by Peter Steinberger, which is the current-main provenance for the affected code path. (Sources/RepoBar/Settings/RepoBrowserRows.swift:85, bc3a4f46e11b)

Likely related people:

  • Peter Steinberger: Current-main blame for RepoBrowserRows, AppState visibility, RepositoryPipeline, and RepoSettingsView points to the v0.7.0 snapshot, and the latest PR branch commits also extend this area. (role: current-main feature introducer and recent area contributor; confidence: high; commits: bc3a4f46e11b, 144ab0fc3fb9, 7962ecf17f76; files: Sources/RepoBar/Settings/RepoBrowserRows.swift, Sources/RepoBar/App/AppState+Visibility.swift, Sources/RepoBarCore/Support/RepositoryPipeline.swift)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal priority bug or improvement with limited blast radius. labels Jun 2, 2026
@devYRPauli devYRPauli force-pushed the fix/pinned-hidden-duplicate-row-id branch from 4badff3 to 865f5bc Compare June 2, 2026 17:13
@devYRPauli

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 5, 2026

Copy link
Copy Markdown

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

Re-review progress:

@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. labels Jun 5, 2026
devYRPauli and others added 4 commits June 5, 2026 14:12
…ed and hidden

A repository that ends up in both the pinned and hidden lists produced two
settings-table rows sharing the same Identifiable id, which breaks SwiftUI
Table rendering and selection.

RepoBrowserRows.make computed loadedKeys only from loaded repositories, then
appended a manual row for every pinned name not loaded and, separately, for
every hidden name not loaded. The hidden loop never checked against rows the
pinned loop had already appended, so a name in both lists (and not loaded) was
emitted twice with the same id = normalized(fullName).

The root enabler was addPinned, which appended to the pinned list without
removing the repo from the hidden list, unlike its mirror hide() which already
removes from pinned. So hide(X) then pin(X) left X in both lists.

Fixes:
- RepoBrowserRows.make now tracks every already-emitted key (loaded plus manual)
  and skips duplicates, so it can never emit two rows with the same id regardless
  of how the lists were populated. Pinned is processed first, so a name in both
  lists wins as pinned, matching the user's explicit intent.
- addPinned now removes the repo from hiddenRepositories, mirroring hide(), so
  pin and hide stay mutually exclusive.

Adds tests for both: make() with the same name pinned and hidden yields exactly
one unique-id row, and addPinned drops the repo from the hidden list.
@steipete steipete force-pushed the fix/pinned-hidden-duplicate-row-id branch from e6b4ab6 to 7962ecf Compare June 5, 2026 21:14
@steipete steipete merged commit 62c9ae5 into steipete:main Jun 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants