Skip to content

Unify sidebar state into atomic sidebar.json#254

Merged
sbertix merged 4 commits intomainfrom
sbertix/atomic-sidebar
Apr 18, 2026
Merged

Unify sidebar state into atomic sidebar.json#254
sbertix merged 4 commits intomainfrom
sbertix/atomic-sidebar

Conversation

@sbertix
Copy link
Copy Markdown
Collaborator

@sbertix sbertix commented Apr 18, 2026

Summary

  • Collapse five legacy sidebar-state stores into a single @Shared(.sidebar) var sidebar: SidebarState persisted atomically to ~/.supacode/sidebar.json. Fixes the click-revert flicker caused by partial writes across independent @Shared slices.
  • Add a boot-time SidebarPersistenceMigrator that folds repositoryOrderIDs, worktreeOrderByRepository, sidebarCollapsedRepositoryIDs, lastFocusedWorktreeID, legacy archivedWorktreeDates + pre-Auto-delete archived worktrees after a configurable period #214 archivedWorktreeIDs, and settingsFile.pinnedWorktreeIDs into the new file. Gated on schemaVersion >= 1 so a failed write retries next launch; seeded from settingsFile.repositoryRoots so repos with only a main worktree still land; paths normalised through RepositoryPathNormalizer; 3-source worktree-base resolver (root / global defaultWorktreeBaseDirectoryPath / per-repo <root>/supacode.json) so pinned/archived worktrees placed anywhere resolve to their owning repo.
  • reconcileSidebarState seeds .unpinned entries for every live non-main worktree, preserves .archived + .pinned as tombstones for repos that drop out of the roster, and skips the liveness prune on the first .repositoriesLoaded so migrated curation survives the transient roster view.
  • SettingsFeature auto-delete preflight now reads archive timestamps from the canonical @Shared(.sidebar).archivedWorktrees, with ArchivedWorktreeDatesClient reshaped into a read-only shim wired in supacodeApp.makeStore and defaulting to unimplemented(placeholder: []) so a dropped override surfaces loudly in debug/tests and falls back safely in release.

Test plan

  • make check
  • make build-app
  • make test — 792 tests pass (8 pre-existing known-issue flakes unrelated).
  • Smoke-test on a machine with existing archivedWorktreeDates + settings.json.pinnedWorktreeIDs + repositoryOrderIDs + worktreeOrderByRepository — confirm the migrated sidebar.json preserves order, pins, archives (including worktrees under the SupaCode ~/.supacode/repos/<name>/ convention and custom worktree-base dirs).
  • Smoke-test a fresh install — confirm baseline section order from repositoryRoots and empty buckets.
  • Smoke-test upgrade from pre-Auto-delete archived worktrees after a configurable period #214 (only archivedWorktreeIDs present) — confirm archivedAt gets stamped and folded.
  • Spot-check auto-delete period shrink — destructive-confirmation alert fires based on the new canonical source.

sbertix added 4 commits April 18, 2026 20:52
Introduce SidebarState — a nested OrderedDictionary<Repository.ID,
Section> where each Section owns buckets: OrderedDictionary<Bucket.ID,
Bucket>, each Bucket owns items: OrderedDictionary<Worktree.ID, Item>,
and each Item carries only an optional archivedAt timestamp.
Bucket.ID is an enum with .pinned / .unpinned / .archived; readers
access everything via keyed lookup, so bucket iteration order is
never a correctness contract.

Mutations go through typed primitives that take full coordinates
(repo + worktree + source bucket) so every helper is O(1) by
construction. move, insert, archive, unarchive, remove, reorder,
plus `currentBucket(of:in:)` + `removeAnywhere(worktree:in:)` for
the "don't know the bucket" callers (archive, delete). Callers
always know the source bucket from their reducer context, which
keeps the helper layer tiny and eliminates defensive scans.

Persisted atomically to `~/.supacode/sidebar.json` via SidebarKey
mirroring LayoutsKey. The persisted URL goes through the new
`\.sidebarFileURL` dependency so tests can point the SharedKey at
a temp-directory URL instead of the user's real home path. Decode
failures rename the corrupt file to `sidebar.json.corrupt-<ISO8601>`
at warning log level before falling back to empty, so the next save
can't silently overwrite recoverable bytes.

SidebarStateTests (16) pin mutation + Codable semantics + the
on-disk bucket key strings. SidebarPersistenceKeyTests exercise
the corrupt-file rename path against an isolated temp dir.
Fold the six legacy sources — three UserDefaults appStorage blobs
(sidebarCollapsedRepositoryIDs, repositoryOrderIDs,
worktreeOrderByRepository, lastFocusedWorktreeID), one additional
appStorage dict (archivedWorktreeDates), and settingsFile
.pinnedWorktreeIDs — into the bucketed SidebarState on first launch
of the new schema.

Idempotency gates solely on whether sidebar.json exists. The
migrator writes sidebar.json first via the atomic
SettingsFileStorage, then clears legacy UserDefaults blobs. A
crash before the write lands leaves legacy sources intact for the
next launch to retry. A crash between write and clear leaves
orphan blobs that no live reader touches (the file gate short-
circuits before any legacy read runs), so they're inert.

Orphan pinned / archived worktrees whose owning repo isn't in the
legacy row-order data are placed by prefix-matching worktree paths
against settingsFile.repositoryRoots (longest match wins, trailing
slashes stripped so legacy roots like "/tmp/repo-a/" still match
the worktree path). Unplaceable IDs log a warning with the drop
count instead of disappearing silently. The file-exists check is
a closure parameter so tests can stub it without subclassing
FileManager.

SidebarPersistenceMigratorTests cover happy-path, noop-when-file-
exists, fresh install, orphan-pinned rescue via prefix match,
longest-nested-root picks, non-parent-prefix rejection, trailing-
slash root handling, and the translate() scheme guard.
Retire the five legacy sidebar-state slices — three
@shared(.appStorage) blobs (sidebarCollapsedRepositoryIDs,
repositoryOrderIDs, worktreeOrderByRepository, lastFocusedWorktreeID),
one SettingsFile slice (pinnedWorktreeIDs via PinnedWorktreeIDsKey),
and one standalone store (archivedWorktreeDates via
ArchivedWorktreeDatesClient) — in favour of a single @shared(.sidebar)
that owns them all.

Every co-mutating reducer action folds into one state.$sidebar.withLock
so the SharedKey emits a single atomic file update. Archive, pin,
and unpin use `currentBucket(of:in:)` to resolve the source bucket
at the action boundary and pass it into the O(1) mutation API; the
worktree-deleted cleanup uses `removeAnywhere(worktree:in:)` since
the worktree is going away entirely. unarchiveWorktree reads the
owning repository via `repositoryID(containing:)` and forwards to
`sidebar.unarchive`, which drops from `.archived` and reinserts at
the top of `.unpinned`.

pruneSidebarState runs on every `.repositoriesLoaded`: rebuilds
`sections` once, drops vanished/main worktrees from `.pinned` /
`.unpinned`, preserves `.archived` items unconditionally (they ARE
the archive record), and seeds a default `.unpinned` entry for
every live non-main worktree not yet curated. An equality gate
short-circuits the withLock + atomic file write when the rebuilt
`sections` matches the current one — branch-flutter reloads no
longer thrash the disk.

AppFeature's two saveLastFocusedWorktreeID call-sites now write
directly to `$sidebar.withLock { $0.focusedWorktreeID = ... }` via
the sibling scope on RepositoriesFeature.State.

WorktreeRowsView no longer filters rows by linked status — no such
concept on this branch — so the pin / archive / delete affordances
apply to every worktree row.

Tests covering the retired persistence client paths are removed;
reducer tests are rewritten to assert against the bucketed shape
(state.sidebar.sections[repo]?.buckets[.pinned]?.items[wt]),
flipping to exhaustivity-off where the @shared(.sidebar) diff would
otherwise drown out the per-action assertions.
…erve first-load curation

- Migrator gates idempotency on `schemaVersion >= 1` (not just file
  existence) and stamps `1` on successful write. Failed writes leave
  legacy sources intact so the next launch retries instead of latching
  on an empty mutation-written file.
- Migrator seeds sections from `settingsFile.repositoryRoots` as the
  baseline and applies `repositoryOrderIDs` as a move-to-top override,
  so repos with only a main worktree still get an entry in sidebar.json
  and the user-visible repo order is preserved.
- All legacy-path inputs flow through `RepositoryPathNormalizer.normalize(_:)`
  so migrated IDs match the canonical `URL(fileURLWithPath:).standardizedFileURL`
  shape that live `Repository.id` / `Worktree.id` use.
- New 3-source worktree-base resolver: given `legacyRoots`, the migrator
  builds candidate paths covering the root itself, the global
  `settingsFile.global.defaultWorktreeBaseDirectoryPath` override, and
  the per-repo `<root>/supacode.json` override (read synchronously via
  `RepositoryLocalSettingsStorage`). Longest-prefix match returns the
  owning root, so pinned/archived worktrees placed under any of the
  three bases resolve correctly.
- Migrator reads pre-#214 `archivedWorktreeIDs: [String]` in addition
  to `archivedWorktreeDates: [String: Date]`, stamping `Date.now` on
  each ID-only entry. Direct upgraders from pre-#214 builds no longer
  lose archive status.
- Silent-drop warnings became named info-level log lines so dropped
  orphans are greppable.
- `reconcileSidebarState` (renamed from `pruneSidebarState`) gains
  `pruneLivenessAgainstRoster`; on the first `.repositoriesLoaded`
  (`isInitialLoadComplete == false`) the liveness prune is skipped so
  migrated curation survives a transient roster view. Always preserves
  `.archived` + `.pinned` as stripped tombstones for repos that drop
  out of `availableRepoIDs`.
- `SettingsFeature` auto-delete preflight routes through
  `@Shared(.sidebar).archivedWorktrees` via an
  `unimplemented(placeholder: [])`-guarded client shim. Forgetting the
  override fails loud in debug/tests and falls back safely in release.
- Drop the redundant first branch of `isWorktreePinned`.
- Test coverage: pre-#214 archive migration, baseline order from
  repositoryRoots, legacyOrder override, non-canonical path
  normalization, first-load reconcile preservation, default/global/
  per-repo worktree-base resolution, pinned/archived under
  `~/.supacode/repos/<name>/`. Existing tests keep hybrid `.off` +
  post-hoc `#expect` on the noisy archive chain.
@tuist
Copy link
Copy Markdown

tuist Bot commented Apr 18, 2026

🛠️ Tuist Run Report 🛠️

Builds 🔨

Scheme Status Duration Commit
supacode 36.8s a8641f03e

@sbertix sbertix merged commit 7981cf3 into main Apr 18, 2026
2 checks passed
@sbertix sbertix deleted the sbertix/atomic-sidebar branch April 18, 2026 23:34
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