Unify sidebar state into atomic `sidebar.json` (#254)
* Scaffold bucketed SidebarState + SharedKey
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.
* Add boot-time migrator from legacy sidebar keys to sidebar.json
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.
* Wire reducer + views onto bucketed SidebarState
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.
* Harden sidebar migration: seed from roots, normalize, tombstone, preserve 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.
authored by