native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

Stefano Bertagno and committed by
GitHub
7981cf34 788dcff4

+3293 -963
+3
.swiftlint.yml
··· 36 36 warning: 100 37 37 error: 150 38 38 39 + nesting: 40 + type_level: 2 41 + 39 42 redundant_discardable_let: 40 43 ignore_swiftui_view_bodies: true 41 44
+2
Project.swift
··· 41 41 .external(name: "Dependencies"), 42 42 .external(name: "IdentifiedCollections"), 43 43 .external(name: "Kingfisher"), 44 + .external(name: "OrderedCollections"), 44 45 .external(name: "PostHog"), 45 46 .external(name: "Sentry"), 46 47 .external(name: "Sharing"), ··· 59 60 .external(name: "Dependencies"), 60 61 .external(name: "DependenciesTestSupport"), 61 62 .external(name: "IdentifiedCollections"), 63 + .external(name: "OrderedCollections"), 62 64 .external(name: "Sharing"), 63 65 ] 64 66
+10 -6
SupacodeSettingsFeature/Reducer/SettingsFeature.swift
··· 457 457 return persist(state) 458 458 } 459 459 // Check how many archived worktrees would be auto-deleted under the new period. 460 - return .run { [now] send in 461 - let archivedDates = await archivedWorktreeDatesClient.load() 462 - let cutoff = now.addingTimeInterval(-Double(newPeriod.rawValue) * secondsPerDay) 463 - let affectedCount = archivedDates.values.filter { $0 <= cutoff }.count 464 - await send(.resolvedAutoDeleteAffectedCount(newPeriod, affectedCount: affectedCount)) 465 - } 460 + // The timestamps come from the `archivedWorktreeDatesClient` 461 + // override wired in `supacodeApp`, which bridges the 462 + // canonical `@Shared(.sidebar)` archived bucket into this 463 + // package. Reading legacy `@Shared(.appStorage(...))` here 464 + // would silently return `[]` post-migration and let the 465 + // next reducer pass destroy everything older than the cutoff. 466 + let archivedDates = archivedWorktreeDatesClient.load() 467 + let cutoff = now.addingTimeInterval(-Double(newPeriod.rawValue) * secondsPerDay) 468 + let affectedCount = archivedDates.filter { $0 <= cutoff }.count 469 + return .send(.resolvedAutoDeleteAffectedCount(newPeriod, affectedCount: affectedCount)) 466 470 467 471 case .resolvedAutoDeleteAffectedCount(let newPeriod, let affectedCount): 468 472 guard affectedCount > 0 else {
+12 -4
SupacodeSettingsShared/BusinessLogic/RepositoryLocalSettingsPersistence.swift
··· 1 1 import Dependencies 2 2 import Foundation 3 3 4 - nonisolated struct RepositoryLocalSettingsStorage: Sendable { 5 - var load: @Sendable (URL) throws -> Data 6 - var save: @Sendable (Data, URL) throws -> Void 4 + public nonisolated struct RepositoryLocalSettingsStorage: Sendable { 5 + public var load: @Sendable (URL) throws -> Data 6 + public var save: @Sendable (Data, URL) throws -> Void 7 + 8 + public init( 9 + load: @escaping @Sendable (URL) throws -> Data, 10 + save: @escaping @Sendable (Data, URL) throws -> Void 11 + ) { 12 + self.load = load 13 + self.save = save 14 + } 7 15 } 8 16 9 17 nonisolated enum RepositoryLocalSettingsStorageKey: DependencyKey { ··· 23 31 } 24 32 25 33 extension DependencyValues { 26 - nonisolated var repositoryLocalSettingsStorage: RepositoryLocalSettingsStorage { 34 + public nonisolated var repositoryLocalSettingsStorage: RepositoryLocalSettingsStorage { 27 35 get { self[RepositoryLocalSettingsStorageKey.self] } 28 36 set { self[RepositoryLocalSettingsStorageKey.self] = newValue } 29 37 }
+15 -5
SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift
··· 107 107 } 108 108 109 109 public nonisolated enum RepositoryPathNormalizer { 110 + /// Canonical single-path normalisation. Returns `nil` for empty 111 + /// or whitespace-only inputs so callers can drop bogus entries 112 + /// without constructing a throwaway array first. Every 113 + /// repository / worktree identifier at rest goes through this 114 + /// codepath so string comparisons against live `Repository.ID` 115 + /// and `Worktree.ID` values stay consistent. 116 + public static func normalize(_ path: String) -> String? { 117 + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) 118 + guard !trimmed.isEmpty else { return nil } 119 + return URL(fileURLWithPath: trimmed) 120 + .standardizedFileURL 121 + .path(percentEncoded: false) 122 + } 123 + 110 124 public static func normalize(_ paths: [String]) -> [String] { 111 125 var seen = Set<String>() 112 126 var normalized: [String] = [] 113 127 normalized.reserveCapacity(paths.count) 114 128 for path in paths { 115 - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) 116 - guard !trimmed.isEmpty else { continue } 117 - let resolved = URL(fileURLWithPath: trimmed) 118 - .standardizedFileURL 119 - .path(percentEncoded: false) 129 + guard let resolved = normalize(path) else { continue } 120 130 if seen.insert(resolved).inserted { 121 131 normalized.append(resolved) 122 132 }
+19 -39
SupacodeSettingsShared/Clients/Settings/ArchivedWorktreeDatesClient.swift
··· 1 - import ComposableArchitecture 1 + import Dependencies 2 2 import Foundation 3 - import Sharing 4 3 5 - public nonisolated let archivedWorktreeDatesStorageKey = "archivedWorktreeDates" 6 4 public nonisolated let secondsPerDay: TimeInterval = 86400 7 5 6 + /// Read-only view over archived-worktree timestamps used by the 7 + /// settings auto-delete affected-count preflight. The canonical 8 + /// source of truth is `@Shared(.sidebar)`, which is declared in the 9 + /// `supacode` app module and therefore out of reach of this shared 10 + /// package. The app overrides `liveValue` at startup to bridge the 11 + /// sidebar bucket into this package; tests inject timestamps 12 + /// directly. 8 13 public nonisolated struct ArchivedWorktreeDatesClient: Sendable { 9 - public var load: @Sendable () async -> [String: Date] 10 - public var save: @Sendable ([String: Date]) async -> Void 14 + public var load: @Sendable () -> [Date] 11 15 12 - public init( 13 - load: @escaping @Sendable () async -> [String: Date], 14 - save: @escaping @Sendable ([String: Date]) async -> Void 15 - ) { 16 + public init(load: @escaping @Sendable () -> [Date]) { 16 17 self.load = load 17 - self.save = save 18 18 } 19 19 } 20 20 21 21 extension ArchivedWorktreeDatesClient: DependencyKey { 22 + /// `unimplemented` surfaces a runtime warning in debug and fails 23 + /// tests if nobody registered the real reader — the settings 24 + /// package can't reach `@Shared(.sidebar)` itself, so the app 25 + /// module MUST override this in `supacodeApp.makeStore(_:)`. The 26 + /// `placeholder: []` keeps release builds behaving like a user 27 + /// with no archived worktrees rather than crashing if the 28 + /// override is ever dropped. 22 29 public static let liveValue = ArchivedWorktreeDatesClient( 23 - load: { 24 - let logger = SupaLogger("ArchivedWorktreeDates") 25 - @Shared(.appStorage(archivedWorktreeDatesStorageKey)) var dates: [String: Date] = [:] 26 - let normalizedDates = RepositoryPathNormalizer.normalizeDictionaryKeys(dates) 27 - if normalizedDates != dates { 28 - $dates.withLock { $0 = normalizedDates } 29 - } 30 - guard normalizedDates.isEmpty else { 31 - return normalizedDates 32 - } 33 - @Shared(.appStorage("archivedWorktreeIDs")) var legacyIDs: [String] = [] 34 - let normalizedLegacyIDs = RepositoryPathNormalizer.normalize(legacyIDs) 35 - guard !normalizedLegacyIDs.isEmpty else { 36 - return [:] 37 - } 38 - let now = Date() 39 - let migrated = Dictionary(uniqueKeysWithValues: normalizedLegacyIDs.map { ($0, now) }) 40 - logger.info("Migrating \(migrated.count) archived worktree(s) from legacy key.") 41 - $dates.withLock { $0 = migrated } 42 - $legacyIDs.withLock { $0 = [] } 43 - return migrated 44 - }, 45 - save: { dates in 46 - @Shared(.appStorage(archivedWorktreeDatesStorageKey)) var sharedDates: [String: Date] = [:] 47 - let normalizedDates = RepositoryPathNormalizer.normalizeDictionaryKeys(dates) 48 - $sharedDates.withLock { $0 = normalizedDates } 49 - } 30 + load: unimplemented("ArchivedWorktreeDatesClient.load", placeholder: []) 50 31 ) 51 32 52 33 public static let testValue = ArchivedWorktreeDatesClient( 53 - load: { [:] }, 54 - save: { _ in } 34 + load: unimplemented("ArchivedWorktreeDatesClient.load", placeholder: []) 55 35 ) 56 36 } 57 37
+4
SupacodeSettingsShared/Support/SupacodePaths.swift
··· 85 85 baseDirectory.appending(path: "settings.json", directoryHint: .notDirectory) 86 86 } 87 87 88 + public static var sidebarURL: URL { 89 + baseDirectory.appending(path: "sidebar.json", directoryHint: .notDirectory) 90 + } 91 + 88 92 public static func repositorySettingsURL(for rootURL: URL) -> URL { 89 93 rootURL.standardizedFileURL.appending(path: "supacode.json", directoryHint: .notDirectory) 90 94 }
+29
supacode/App/supacodeApp.swift
··· 118 118 @MainActor init() { 119 119 NSWindow.allowsAutomaticWindowTabbing = false 120 120 UserDefaults.standard.set(200, forKey: "NSInitialToolTipDelay") 121 + // Fold the six legacy sidebar-state sources into `sidebar.json` 122 + // before any @Shared binding observes them: 123 + // 1. `@Shared(.appStorage("sidebarCollapsedRepositoryIDs"))`. 124 + // 2. `@Shared(.appStorage("repositoryOrderIDs"))`. 125 + // 3. `@Shared(.appStorage("worktreeOrderByRepository"))`. 126 + // 4. `@Shared(.appStorage("lastFocusedWorktreeID"))`. 127 + // 5. `@Shared(.appStorage("archivedWorktreeDates"))` (the 128 + // legacy key; the client that wrapped it is being retired 129 + // in a parallel task). 130 + // 6. `settingsFile.pinnedWorktreeIDs` (the `SettingsFile` 131 + // slice). 132 + // Idempotent — gates on whether `sidebar.json` already exists 133 + // AND carries `schemaVersion >= 1` — so the downgrade → 134 + // re-upgrade path can't double-migrate, while a prior 135 + // half-finished migration that left a `schemaVersion == 0` file 136 + // still gets retried. 137 + SidebarPersistenceMigrator.migrateIfNeeded() 121 138 @Shared(.settingsFile) var settingsFile 122 139 let initialSettings = settingsFile.global 123 140 let infoDictionary = Bundle.main.infoDictionary ?? [:] ··· 209 226 }, 210 227 events: { 211 228 worktreeInfoWatcher.eventStream() 229 + } 230 + ) 231 + // Bridge the archived-worktree timestamps from the canonical 232 + // `@Shared(.sidebar)` bucket into the `SupacodeSettingsShared` 233 + // package, which cannot see `SidebarState` directly. The 234 + // settings auto-delete preflight uses this to decide whether 235 + // to show a destructive-confirmation alert before shortening 236 + // the retention window. 237 + values.archivedWorktreeDatesClient = ArchivedWorktreeDatesClient( 238 + load: { 239 + @Shared(.sidebar) var sidebar: SidebarState 240 + return sidebar.archivedWorktrees.map(\.archivedAt) 212 241 } 213 242 ) 214 243 }
+7 -105
supacode/Clients/Repositories/RepositoryPersistenceClient.swift
··· 3 3 import Sharing 4 4 import SupacodeSettingsShared 5 5 6 + /// Root-path persistence for the local repository list. All other 7 + /// sidebar slices (pin / collapse / repo order / worktree order / 8 + /// focus / archive) moved to `@Shared(.sidebar)` + the 9 + /// `SidebarPersistenceMigrator` — this client now only owns 10 + /// `repositoryRoots`. 6 11 struct RepositoryPersistenceClient { 7 12 var loadRoots: @Sendable () async -> [String] 8 13 var saveRoots: @Sendable ([String]) async -> Void 9 - var loadPinnedWorktreeIDs: @Sendable () async -> [Worktree.ID] 10 - var savePinnedWorktreeIDs: @Sendable ([Worktree.ID]) async -> Void 11 - var loadArchivedWorktreeDates: @Sendable () async -> [Worktree.ID: Date] 12 - var saveArchivedWorktreeDates: @Sendable ([Worktree.ID: Date]) async -> Void 13 - var loadRepositoryOrderIDs: @Sendable () async -> [Repository.ID] 14 - var saveRepositoryOrderIDs: @Sendable ([Repository.ID]) async -> Void 15 - var loadWorktreeOrderByRepository: @Sendable () async -> [Repository.ID: [Worktree.ID]] 16 - var saveWorktreeOrderByRepository: @Sendable ([Repository.ID: [Worktree.ID]]) async -> Void 17 - var loadLastFocusedWorktreeID: @Sendable () async -> Worktree.ID? 18 - var saveLastFocusedWorktreeID: @Sendable (Worktree.ID?) async -> Void 19 14 } 20 15 21 16 extension RepositoryPersistenceClient: DependencyKey { 22 17 static let liveValue: RepositoryPersistenceClient = { 23 - let archivedWorktreeDatesClient = ArchivedWorktreeDatesClient.liveValue 24 - return RepositoryPersistenceClient( 18 + RepositoryPersistenceClient( 25 19 loadRoots: { 26 20 @Shared(.repositoryRoots) var roots: [String] 27 21 return roots ··· 31 25 $sharedRoots.withLock { 32 26 $0 = roots 33 27 } 34 - }, 35 - loadPinnedWorktreeIDs: { 36 - @Shared(.pinnedWorktreeIDs) var pinned: [String] 37 - return pinned 38 - }, 39 - savePinnedWorktreeIDs: { ids in 40 - @Shared(.pinnedWorktreeIDs) var sharedPinned: [String] 41 - $sharedPinned.withLock { 42 - $0 = ids 43 - } 44 - }, 45 - loadArchivedWorktreeDates: { 46 - await archivedWorktreeDatesClient.load() 47 - }, 48 - saveArchivedWorktreeDates: { dates in 49 - await archivedWorktreeDatesClient.save(dates) 50 - }, 51 - loadRepositoryOrderIDs: { 52 - @Shared(.appStorage("repositoryOrderIDs")) var order: [Repository.ID] = [] 53 - return RepositoryOrderNormalizer.normalizeRepositoryIDs(order) 54 - }, 55 - saveRepositoryOrderIDs: { ids in 56 - @Shared(.appStorage("repositoryOrderIDs")) var sharedOrder: [Repository.ID] = [] 57 - let normalized = RepositoryOrderNormalizer.normalizeRepositoryIDs(ids) 58 - $sharedOrder.withLock { 59 - $0 = normalized 60 - } 61 - }, 62 - loadWorktreeOrderByRepository: { 63 - @Shared(.appStorage("worktreeOrderByRepository")) var order: [Repository.ID: [Worktree.ID]] = [:] 64 - return RepositoryOrderNormalizer.normalizeWorktreeOrderByRepository(order) 65 - }, 66 - saveWorktreeOrderByRepository: { order in 67 - @Shared(.appStorage("worktreeOrderByRepository")) var sharedOrder: [Repository.ID: [Worktree.ID]] = [:] 68 - let normalized = RepositoryOrderNormalizer.normalizeWorktreeOrderByRepository(order) 69 - $sharedOrder.withLock { 70 - $0 = normalized 71 - } 72 - }, 73 - loadLastFocusedWorktreeID: { 74 - @Shared(.appStorage("lastFocusedWorktreeID")) var lastFocused: Worktree.ID? 75 - return lastFocused 76 - }, 77 - saveLastFocusedWorktreeID: { id in 78 - @Shared(.appStorage("lastFocusedWorktreeID")) var sharedLastFocused: Worktree.ID? 79 - $sharedLastFocused.withLock { 80 - $0 = id 81 - } 82 28 } 83 29 ) 84 30 }() 85 31 static let testValue = RepositoryPersistenceClient( 86 32 loadRoots: { [] }, 87 - saveRoots: { _ in }, 88 - loadPinnedWorktreeIDs: { [] }, 89 - savePinnedWorktreeIDs: { _ in }, 90 - loadArchivedWorktreeDates: { [:] }, 91 - saveArchivedWorktreeDates: { _ in }, 92 - loadRepositoryOrderIDs: { [] }, 93 - saveRepositoryOrderIDs: { _ in }, 94 - loadWorktreeOrderByRepository: { [:] }, 95 - saveWorktreeOrderByRepository: { _ in }, 96 - loadLastFocusedWorktreeID: { nil }, 97 - saveLastFocusedWorktreeID: { _ in } 33 + saveRoots: { _ in } 98 34 ) 99 35 } 100 36 ··· 104 40 set { self[RepositoryPersistenceClient.self] = newValue } 105 41 } 106 42 } 107 - 108 - nonisolated enum RepositoryOrderNormalizer { 109 - static func normalizeRepositoryIDs(_ ids: [Repository.ID]) -> [Repository.ID] { 110 - RepositoryPathNormalizer.normalize(ids) 111 - } 112 - 113 - static func normalizeWorktreeOrderByRepository( 114 - _ order: [Repository.ID: [Worktree.ID]] 115 - ) -> [Repository.ID: [Worktree.ID]] { 116 - var normalized: [Repository.ID: [Worktree.ID]] = [:] 117 - for (repoID, worktreeIDs) in order { 118 - guard let normalizedRepoID = normalizePath(repoID) else { continue } 119 - let normalizedWorktreeIDs = RepositoryPathNormalizer.normalize(worktreeIDs) 120 - guard !normalizedWorktreeIDs.isEmpty else { continue } 121 - if var existing = normalized[normalizedRepoID] { 122 - for id in normalizedWorktreeIDs where !existing.contains(id) { 123 - existing.append(id) 124 - } 125 - normalized[normalizedRepoID] = existing 126 - } else { 127 - normalized[normalizedRepoID] = normalizedWorktreeIDs 128 - } 129 - } 130 - return normalized 131 - } 132 - 133 - private static func normalizePath(_ path: String) -> String? { 134 - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) 135 - guard !trimmed.isEmpty else { return nil } 136 - return URL(fileURLWithPath: trimmed) 137 - .standardizedFileURL 138 - .path(percentEncoded: false) 139 - } 140 - }
+14 -16
supacode/Features/App/Reducer/AppFeature.swift
··· 152 152 153 153 case .repositories(.delegate(.selectedWorktreeChanged(let worktree))): 154 154 let lastFocusedWorktreeID = worktree?.id 155 - let repositoryPersistence = repositoryPersistence 156 155 guard let worktree else { 157 156 state.openActionSelection = .finder 158 157 state.scripts = [] 159 - var effects: [Effect<Action>] = [ 158 + // Selecting the archived list must NOT overwrite the last 159 + // focused live worktree — preserve `focusedWorktreeID` so 160 + // returning from archives restores the prior row. 161 + if !state.repositories.isShowingArchivedWorktrees { 162 + state.repositories.$sidebar.withLock { sidebar in 163 + sidebar.focusedWorktreeID = lastFocusedWorktreeID 164 + } 165 + } 166 + return .merge( 160 167 .run { _ in 161 168 await terminalClient.send(.setSelectedWorktreeID(nil)) 162 169 }, 163 170 .run { _ in 164 171 await worktreeInfoWatcher.send(.setSelectedWorktreeID(nil)) 165 - }, 166 - ] 167 - if !state.repositories.isShowingArchivedWorktrees { 168 - effects.insert( 169 - .run { _ in 170 - await repositoryPersistence.saveLastFocusedWorktreeID(lastFocusedWorktreeID) 171 - }, 172 - at: 0 173 - ) 174 - } 175 - return .merge(effects) 172 + } 173 + ) 176 174 } 177 175 let rootURL = worktree.repositoryRootURL 178 176 let worktreeID = worktree.id 177 + state.repositories.$sidebar.withLock { sidebar in 178 + sidebar.focusedWorktreeID = lastFocusedWorktreeID 179 + } 179 180 @Shared(.repositorySettings(rootURL)) var repositorySettings 180 181 let settings = repositorySettings 181 182 return .merge( 182 - .run { _ in 183 - await repositoryPersistence.saveLastFocusedWorktreeID(lastFocusedWorktreeID) 184 - }, 185 183 .run { _ in 186 184 await terminalClient.send(.setSelectedWorktreeID(worktree.id)) 187 185 },
+146
supacode/Features/Repositories/BusinessLogic/SidebarPersistenceKey.swift
··· 1 + import Dependencies 2 + import Foundation 3 + import Sharing 4 + import SupacodeSettingsShared 5 + 6 + /// Stable identity for the sidebar `SharedKey`. Mirrors 7 + /// `LayoutsKeyID` — a dummy struct so `SharedKey.id` can 8 + /// discriminate this key from every other `SharedKey` in the app. 9 + nonisolated struct SidebarKeyID: Hashable, Sendable {} 10 + 11 + /// Dependency key that hands back the file URL `SidebarKey` reads 12 + /// from and writes to. Production wires to `SupacodePaths.sidebarURL`; 13 + /// tests override with a temp-directory URL so the SharedKey can be 14 + /// exercised hermetically (the live corrupt-file path renames the 15 + /// bad file, which we don't want touching the user's real 16 + /// `~/.supacode/sidebar.json`). 17 + public nonisolated enum SidebarFileURLKey: DependencyKey { 18 + public static var liveValue: URL { SupacodePaths.sidebarURL } 19 + public static var previewValue: URL { SupacodePaths.sidebarURL } 20 + public static var testValue: URL { 21 + FileManager.default.temporaryDirectory 22 + .appending( 23 + path: "supacode-sidebar-test-\(UUID().uuidString).json", 24 + directoryHint: .notDirectory 25 + ) 26 + } 27 + } 28 + 29 + extension DependencyValues { 30 + public nonisolated var sidebarFileURL: URL { 31 + get { self[SidebarFileURLKey.self] } 32 + set { self[SidebarFileURLKey.self] = newValue } 33 + } 34 + } 35 + 36 + /// Custom `SharedKey` that persists the nested `SidebarState` to the 37 + /// `\.sidebarFileURL` dependency via the shared `SettingsFileStorage`. 38 + /// Modelled on `LayoutsKey` — same load/save/subscribe shape, same 39 + /// atomic-write guarantee from the live storage. 40 + nonisolated struct SidebarKey: SharedKey { 41 + private static let logger = SupaLogger("Sidebar") 42 + 43 + var id: SidebarKeyID { SidebarKeyID() } 44 + 45 + func load( 46 + context _: LoadContext<SidebarState>, 47 + continuation: LoadContinuation<SidebarState> 48 + ) { 49 + @Dependency(\.settingsFileStorage) var storage 50 + @Dependency(\.sidebarFileURL) var url 51 + let data: Data 52 + do { 53 + data = try storage.load(url) 54 + } catch { 55 + // File does not exist yet — expected on first run and on 56 + // installs whose legacy state hasn't been migrated. 57 + continuation.resumeReturningInitialValue() 58 + return 59 + } 60 + do { 61 + let state = try JSONDecoder().decode(SidebarState.self, from: data) 62 + continuation.resume(returning: state) 63 + } catch { 64 + // Move the corrupt file aside before falling back to an empty 65 + // state — otherwise the next `save` atomically overwrites the 66 + // bytes we might need to recover from. A decode failure always 67 + // logs at warning level so the operator sees the corruption; 68 + // if the subsequent rename also fails, we log a second warning 69 + // line so the double-failure is unambiguous in the logs. When 70 + // the corrupt file has already been renamed by a prior run we 71 + // skip the second log entirely. Non-recoverable without manual 72 + // intervention, but leaving the app stuck in a "refuse to 73 + // save" sentinel state would create a worse UX. 74 + Self.logger.warning( 75 + "Failed to decode sidebar state from \(url.path(percentEncoded: false)): \(error)" 76 + ) 77 + Self.renameCorruptFile(at: url) 78 + continuation.resumeReturningInitialValue() 79 + } 80 + } 81 + 82 + /// Moves a corrupt `sidebar.json` aside to 83 + /// `sidebar.json.corrupt-<ISO8601>` so an atomic save from the 84 + /// empty default doesn't overwrite the only on-disk copy of the 85 + /// user's sidebar curation. The `\.settingsFileStorage` dep only 86 + /// exposes `load` / `save`, so the rename goes through 87 + /// `FileManager` directly — a missing or already-renamed file 88 + /// returns without surfacing; the caller always proceeds to the 89 + /// empty fallback. 90 + private static func renameCorruptFile(at url: URL) { 91 + let fileManager = FileManager.default 92 + let sourcePath = url.path(percentEncoded: false) 93 + guard fileManager.fileExists(atPath: sourcePath) else { 94 + return 95 + } 96 + let formatter = ISO8601DateFormatter() 97 + formatter.formatOptions = [.withInternetDateTime] 98 + let timestamp = formatter.string(from: Date()).replacing(":", with: "-") 99 + let destination = url.deletingLastPathComponent() 100 + .appending( 101 + path: "\(url.lastPathComponent).corrupt-\(timestamp)", 102 + directoryHint: .notDirectory 103 + ) 104 + do { 105 + try fileManager.moveItem(at: url, to: destination) 106 + } catch { 107 + Self.logger.warning( 108 + """ 109 + Failed to rename corrupt sidebar file to \(destination.lastPathComponent): \(error). \ 110 + Next save WILL overwrite the corrupt bytes. 111 + """ 112 + ) 113 + } 114 + } 115 + 116 + func subscribe( 117 + context _: LoadContext<SidebarState>, 118 + subscriber _: SharedSubscriber<SidebarState> 119 + ) -> SharedSubscription { 120 + SharedSubscription {} 121 + } 122 + 123 + func save( 124 + _ value: SidebarState, 125 + context _: SaveContext, 126 + continuation: SaveContinuation 127 + ) { 128 + @Dependency(\.settingsFileStorage) var storage 129 + @Dependency(\.sidebarFileURL) var url 130 + do { 131 + let encoder = JSONEncoder() 132 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 133 + let data = try encoder.encode(value) 134 + try storage.save(data, url) 135 + continuation.resume() 136 + } catch { 137 + continuation.resume(throwing: error) 138 + } 139 + } 140 + } 141 + 142 + nonisolated extension SharedReaderKey where Self == SidebarKey.Default { 143 + static var sidebar: Self { 144 + Self[SidebarKey(), default: SidebarState()] 145 + } 146 + }
+581
supacode/Features/Repositories/BusinessLogic/SidebarPersistenceMigrator.swift
··· 1 + import Dependencies 2 + import Foundation 3 + import OrderedCollections 4 + import Sharing 5 + import SupacodeSettingsShared 6 + 7 + /// One-shot migration that folds the seven legacy sidebar-state 8 + /// sources into the new `sidebar.json` file on first launch of the 9 + /// new schema. 10 + /// 11 + /// Reads from: 12 + /// - `@Shared(.appStorage("sidebarCollapsedRepositoryIDs"))` — legacy 13 + /// flat list of repo IDs whose sidebar section was collapsed. 14 + /// - `@Shared(.appStorage("repositoryOrderIDs"))` — legacy user- 15 + /// curated repo-row order. 16 + /// - `@Shared(.appStorage("worktreeOrderByRepository"))` — legacy 17 + /// per-repo unpinned worktree-row order. 18 + /// - `@Shared(.appStorage("lastFocusedWorktreeID"))` — legacy focused 19 + /// worktree ID. 20 + /// - `@Shared(.appStorage("archivedWorktreeDates"))` — legacy 21 + /// archived-worktree timestamps dictionary. 22 + /// - `@Shared(.appStorage("archivedWorktreeIDs"))` — pre-#214 23 + /// archived-worktree ID list (no timestamps). Stamped with the 24 + /// current date to preserve the behaviour of the retired 25 + /// `ArchivedWorktreeDatesClient.liveValue.load` one-shot fold. 26 + /// - `@Shared(.settingsFile).pinnedWorktreeIDs` — the modernised 27 + /// pinned list already living in `settings.json`. 28 + /// 29 + /// Writes to: 30 + /// - `~/.supacode/sidebar.json` via the shared 31 + /// `\.settingsFileStorage` dependency — always, even when the 32 + /// migrated state is empty. The file's presence is the sole 33 + /// idempotency signal on future launches, so we always create it. 34 + /// 35 + /// Idempotency is gated on the persisted `schemaVersion`. If 36 + /// `sidebar.json` exists, decodes cleanly, AND carries 37 + /// `schemaVersion >= 1` we skip — including on the downgrade → 38 + /// re-upgrade path, where the older build may have re-populated the 39 + /// legacy UserDefaults blobs but cannot have stamped a migrated 40 + /// schema version on the file. A mere "file exists" check is not 41 + /// enough: if `storage.save(…)` ever failed mid-migration (disk 42 + /// full / permissions / iCloud hiccup) and the first 43 + /// `@Shared(.sidebar)` mutation wrote an empty `SidebarState()` 44 + /// (which defaults `schemaVersion` to `0`), a file-existence gate 45 + /// would short-circuit forever and strand the user's legacy state. 46 + /// Gating on `schemaVersion >= 1` lets the migrator retry in that 47 + /// corner case. 48 + /// 49 + /// Ordering: the new `sidebar.json` is written FIRST (with 50 + /// `schemaVersion = 1`); the legacy sources are cleared AFTER. 51 + /// `SettingsFileStorage.save` is atomic, so a crash before the 52 + /// write lands leaves the legacy sources intact for the next 53 + /// launch to retry. A crash between write and clear leaves orphan 54 + /// UserDefaults blobs that no live reader touches (the file gates 55 + /// everything), so they're inert. Worst case: orphaned 56 + /// UserDefaults storage, not lost curation. 57 + /// 58 + /// Pre-#214 straggler handling: the main schema-version gate is 59 + /// bypassed for a second time if the legacy pre-#214 60 + /// `archivedWorktreeIDs` list is non-empty on disk. The fold that 61 + /// used to live in `ArchivedWorktreeDatesClient.liveValue.load` 62 + /// was retired during the current branch cleanup, so this migrator 63 + /// is the last reader of that key. We run the ID-list fold inline 64 + /// here regardless of the main gate, stamp `Date.now` on each 65 + /// entry (matching the retired client), and clear only after the 66 + /// `sidebar.json` write lands. 67 + enum SidebarPersistenceMigrator { 68 + private static let logger = SupaLogger("SidebarMigration") 69 + 70 + /// Runs the one-shot migration if `sidebar.json` is missing, 71 + /// corrupt, or stamped with a pre-migration `schemaVersion`. 72 + /// 73 + /// - Note: reads `@Shared(.settingsFile)` synchronously; the 74 + /// `SettingsFile` SharedKey must hydrate synchronously on first 75 + /// access for `pinnedWorktreeIDs` and `repositoryRoots` to make 76 + /// it into `sidebar.json`. If that invariant ever changes, this 77 + /// call-site must be reordered to run after the settings file 78 + /// has finished loading. 79 + @MainActor 80 + static func migrateIfNeeded( 81 + fileExists: (URL) -> Bool = { url in 82 + FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) 83 + }, 84 + readFile: (URL) -> Data? = { url in 85 + try? Data(contentsOf: url) 86 + } 87 + ) { 88 + let sidebarURL = SupacodePaths.sidebarURL 89 + 90 + // Legacy UserDefaults blobs are keyed on `Repository.ID = String` 91 + // (bare filesystem paths). Decode explicitly as `[String]` / 92 + // `[String: [String]]` to keep the migrator decoupled from any 93 + // future rename of `Repository.ID`. 94 + @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var legacyCollapsed: [String] = [] 95 + @Shared(.appStorage("repositoryOrderIDs")) var legacyOrder: [String] = [] 96 + @Shared(.appStorage("worktreeOrderByRepository")) var legacyWorktreeOrder: [String: [String]] = [:] 97 + @Shared(.appStorage("lastFocusedWorktreeID")) var legacyFocus: String? 98 + @Shared(.appStorage("archivedWorktreeDates")) var legacyArchived: [String: Date] = [:] 99 + @Shared(.appStorage("archivedWorktreeIDs")) var legacyArchivedIDs: [String] = [] 100 + @Shared(.settingsFile) var settingsFile 101 + 102 + let skipMain = shouldSkipMigration( 103 + sidebarURL: sidebarURL, 104 + fileExists: fileExists, 105 + readFile: readFile 106 + ) 107 + 108 + // Pre-#214 straggler path: the retired 109 + // `ArchivedWorktreeDatesClient.liveValue.load` used to fold the 110 + // `[String]` list into `archivedWorktreeDates` on first read. 111 + // We inherit that job. If the main migration already ran 112 + // (`schemaVersion >= 1`) but the user had a stale ID list sitting 113 + // in UserDefaults, still fold+clear it so we don't strand those 114 + // archive records. Matches the retired client's `Date.now` 115 + // stamp so timestamps line up with the cut-over moment. 116 + if skipMain { 117 + foldLegacyArchivedIDsIntoDictionaryStorage( 118 + legacyArchivedIDs: $legacyArchivedIDs, 119 + legacyArchived: $legacyArchived 120 + ) 121 + return 122 + } 123 + 124 + @Dependency(\.date.now) var now 125 + let legacyRoots = RepositoryPathNormalizer.normalize(settingsFile.repositoryRoots) 126 + let legacyPinnedSet = Set(RepositoryPathNormalizer.normalize(settingsFile.pinnedWorktreeIDs)) 127 + // Merge the pre-#214 ID list into the dated dictionary view 128 + // BEFORE the fold pass so both sources land in the same 129 + // `SidebarState.archived` bucket in one shot. `Date.now` matches 130 + // the retired client's stamp. The dated entry wins on collision 131 + // (authoritative because #214+ timestamps are real, not synthetic). 132 + let mergedArchived = mergeLegacyArchivedIDs( 133 + legacyArchivedIDs: legacyArchivedIDs, 134 + legacyArchived: legacyArchived, 135 + stampedAt: now 136 + ) 137 + 138 + var state = SidebarState() 139 + // Stamp the migrated schema version up-front so every code path 140 + // below — including any early `return` that still writes — emits 141 + // a `schemaVersion >= 1` file and keeps the idempotency gate 142 + // above honest. 143 + state.schemaVersion = 1 144 + 145 + seedSections( 146 + into: &state, 147 + legacyRoots: legacyRoots, 148 + legacyOrder: legacyOrder, 149 + legacyWorktreeOrder: legacyWorktreeOrder, 150 + legacyPinnedSet: legacyPinnedSet 151 + ) 152 + // Build the candidate pool once so `rescueOrphanPinned` and 153 + // `foldArchived` share an identical view. Candidates cover every 154 + // filesystem location a worktree may legitimately live under 155 + // (repo root itself, global-override base, per-repo override 156 + // base, default `~/.supacode/repos/<name>/` convention) so 157 + // prefix resolution works even when the worktree tree and the 158 + // repo-root tree share no common ancestor. 159 + let candidates = rootCandidates(legacyRoots: legacyRoots, settingsFile: settingsFile) 160 + rescueOrphanPinned( 161 + into: &state, 162 + legacyPinnedSet: legacyPinnedSet, 163 + rootCandidates: candidates 164 + ) 165 + applyCollapsedFlags(into: &state, legacyCollapsed: legacyCollapsed) 166 + foldArchived(into: &state, legacyArchived: mergedArchived, rootCandidates: candidates) 167 + 168 + state.focusedWorktreeID = legacyFocus.flatMap(RepositoryPathNormalizer.normalize) 169 + 170 + guard persist(state: state, to: sidebarURL) else { 171 + return 172 + } 173 + 174 + // Clear legacy sources only after the new file landed. A 175 + // crash in this window leaves orphan UserDefaults blobs that 176 + // no live reader touches on next launch (the file gate 177 + // short-circuits before any legacy read runs). 178 + if !legacyCollapsed.isEmpty { 179 + $legacyCollapsed.withLock { $0 = [] } 180 + } 181 + if !legacyOrder.isEmpty { 182 + $legacyOrder.withLock { $0 = [] } 183 + } 184 + if !legacyWorktreeOrder.isEmpty { 185 + $legacyWorktreeOrder.withLock { $0 = [:] } 186 + } 187 + if legacyFocus != nil { 188 + $legacyFocus.withLock { $0 = nil } 189 + } 190 + if !legacyPinnedSet.isEmpty { 191 + $settingsFile.withLock { $0.pinnedWorktreeIDs = [] } 192 + } 193 + if !legacyArchived.isEmpty { 194 + $legacyArchived.withLock { $0 = [:] } 195 + } 196 + if !legacyArchivedIDs.isEmpty { 197 + $legacyArchivedIDs.withLock { $0 = [] } 198 + } 199 + 200 + logger.info( 201 + """ 202 + Migrated sidebar state: \(state.sections.count) section(s), \ 203 + \(legacyPinnedSet.count) pinned worktree(s), \ 204 + \(mergedArchived.count) archived worktree(s), \ 205 + focus=\(state.focusedWorktreeID ?? "nil"). 206 + """ 207 + ) 208 + } 209 + 210 + /// Decodes the existing `sidebar.json` (if present) and returns 211 + /// `true` when it already carries a migrated `schemaVersion`. 212 + /// Any other state (missing file, decode failure, 213 + /// `schemaVersion == 0`) means either a fresh install, a corrupt 214 + /// file (the SharedKey's read path owns rename-aside handling; 215 + /// the migrator overwrites it below), or a prior failed migration 216 + /// that needs to re-run — all of which return `false` here. 217 + private static func shouldSkipMigration( 218 + sidebarURL: URL, 219 + fileExists: (URL) -> Bool, 220 + readFile: (URL) -> Data? 221 + ) -> Bool { 222 + guard fileExists(sidebarURL), 223 + let data = readFile(sidebarURL), 224 + let existing = try? JSONDecoder().decode(SidebarState.self, from: data) 225 + else { 226 + return false 227 + } 228 + return existing.schemaVersion >= 1 229 + } 230 + 231 + /// Merge the pre-#214 `[String]` archived-ID list into the dated 232 + /// `[String: Date]` dictionary, stamping every straggler with 233 + /// `stampedAt` so later passes (`foldArchived`) only need to 234 + /// read one source. The dated dictionary wins on collision — 235 + /// #214+ timestamps are real and authoritative, the ID-list 236 + /// stamp is synthetic. 237 + private static func mergeLegacyArchivedIDs( 238 + legacyArchivedIDs: [String], 239 + legacyArchived: [String: Date], 240 + stampedAt: Date 241 + ) -> [String: Date] { 242 + var merged = legacyArchived 243 + for rawID in legacyArchivedIDs { 244 + guard let normalizedID = RepositoryPathNormalizer.normalize(rawID) else { 245 + continue 246 + } 247 + if merged[normalizedID] == nil { 248 + merged[normalizedID] = stampedAt 249 + } 250 + } 251 + return merged 252 + } 253 + 254 + /// Pre-#214 straggler fold used when the main migration path is 255 + /// skipped (schema already >= 1) but `archivedWorktreeIDs` still 256 + /// has entries. Folds into `archivedWorktreeDates` in UserDefaults 257 + /// — the live `@Shared(.sidebar)` reader's archived-worktree 258 + /// pruner will pick these up on the next refresh. Clears the ID 259 + /// list only after the dictionary write lands. 260 + @MainActor 261 + private static func foldLegacyArchivedIDsIntoDictionaryStorage( 262 + legacyArchivedIDs: Shared<[String]>, 263 + legacyArchived: Shared<[String: Date]> 264 + ) { 265 + let ids = legacyArchivedIDs.wrappedValue 266 + guard !ids.isEmpty else { 267 + return 268 + } 269 + @Dependency(\.date.now) var now 270 + legacyArchived.withLock { dict in 271 + for rawID in ids { 272 + guard let normalizedID = RepositoryPathNormalizer.normalize(rawID) else { 273 + continue 274 + } 275 + if dict[normalizedID] == nil { 276 + dict[normalizedID] = now 277 + } 278 + } 279 + } 280 + legacyArchivedIDs.withLock { $0 = [] } 281 + logger.info("Folded \(ids.count) pre-#214 archived worktree ID(s) into archivedWorktreeDates.") 282 + } 283 + 284 + /// Seeds ordered sections. `repositoryRoots` is the canonical 285 + /// baseline — every known root gets an empty section in settings 286 + /// order — THEN `legacyOrder` is applied as a move-to-top 287 + /// override so repos the user explicitly reordered win. Mirrors 288 + /// the live `orderedRepositoryRoots()` behaviour so users who 289 + /// never dragged a row still see repos in settings order instead 290 + /// of filesystem-discovery order, and repos with only a main 291 + /// worktree (no curated row order, never dragged) don't vanish. 292 + /// 293 + /// After the baseline + override pass, folds per-repo unpinned 294 + /// worktree order into `.pinned` / `.unpinned` buckets; entries 295 + /// that also appear in `legacyPinnedSet` route to `.pinned`. 296 + private static func seedSections( 297 + into state: inout SidebarState, 298 + legacyRoots: [String], 299 + legacyOrder: [String], 300 + legacyWorktreeOrder: [String: [String]], 301 + legacyPinnedSet: Set<Worktree.ID> 302 + ) { 303 + // Canonical baseline: every known root, in settings order. 304 + for root in legacyRoots where state.sections[root] == nil { 305 + state.sections[root] = .init() 306 + } 307 + // Override layer: `legacyOrder` wins — pull matching roots to 308 + // the top in the order the user curated. Roots present in 309 + // `legacyOrder` but missing from `repositoryRoots` are still 310 + // materialised so curated state survives a stale settings file. 311 + var reordered: OrderedDictionary<Repository.ID, SidebarState.Section> = [:] 312 + var seen: Set<Repository.ID> = [] 313 + for raw in legacyOrder { 314 + guard let id = RepositoryPathNormalizer.normalize(raw) else { 315 + continue 316 + } 317 + guard seen.insert(id).inserted else { 318 + continue 319 + } 320 + reordered[id] = state.sections[id] ?? .init() 321 + } 322 + for (id, section) in state.sections where !seen.contains(id) { 323 + reordered[id] = section 324 + } 325 + state.sections = reordered 326 + 327 + for (rawRepoID, worktreeIDs) in legacyWorktreeOrder { 328 + guard let repoID = RepositoryPathNormalizer.normalize(rawRepoID) else { 329 + continue 330 + } 331 + for rawWorktreeID in worktreeIDs { 332 + guard let worktreeID = RepositoryPathNormalizer.normalize(rawWorktreeID) else { 333 + continue 334 + } 335 + let bucketID: SidebarState.BucketID = 336 + legacyPinnedSet.contains(worktreeID) ? .pinned : .unpinned 337 + state.insert(worktree: worktreeID, in: repoID, bucket: bucketID) 338 + } 339 + } 340 + } 341 + 342 + /// Rescues pinned worktrees that didn't appear in the row-order 343 + /// map (their repo had no curated order). Prefix-matches the path 344 + /// against the precomputed `rootCandidates` pool (repo root plus 345 + /// every worktree-base location the convention resolver emitted) 346 + /// to find the owning repo. Unplaceable entries log the specific 347 + /// IDs at info level so operators can grep logs. 348 + private static func rescueOrphanPinned( 349 + into state: inout SidebarState, 350 + legacyPinnedSet: Set<Worktree.ID>, 351 + rootCandidates: [(candidate: String, owningRoot: String)] 352 + ) { 353 + var placedPinned: Set<Worktree.ID> = [] 354 + for section in state.sections.values { 355 + if let pinned = section.buckets[.pinned]?.items.keys { 356 + placedPinned.formUnion(pinned) 357 + } 358 + } 359 + var unplacedPinnedIDs: [Worktree.ID] = [] 360 + for pinnedID in legacyPinnedSet where !placedPinned.contains(pinnedID) { 361 + if let repoID = repositoryID(owningWorktreeID: pinnedID, amongLegacyRoots: rootCandidates) { 362 + state.insert(worktree: pinnedID, in: repoID, bucket: .pinned) 363 + } else { 364 + unplacedPinnedIDs.append(pinnedID) 365 + } 366 + } 367 + guard !unplacedPinnedIDs.isEmpty else { 368 + return 369 + } 370 + logger.info( 371 + "Dropped \(unplacedPinnedIDs.count) orphan pinned worktree(s) with no matching root." 372 + ) 373 + for id in unplacedPinnedIDs { 374 + logger.info("Dropped orphan pinned worktree: \(id).") 375 + } 376 + } 377 + 378 + /// Applies the collapsed bit. May introduce a new section entry 379 + /// if the repo was collapsed but had no curated order. 380 + private static func applyCollapsedFlags( 381 + into state: inout SidebarState, 382 + legacyCollapsed: [String] 383 + ) { 384 + for raw in legacyCollapsed { 385 + guard let id = RepositoryPathNormalizer.normalize(raw) else { 386 + continue 387 + } 388 + var section = state.sections[id] ?? .init() 389 + section.collapsed = true 390 + state.sections[id] = section 391 + } 392 + } 393 + 394 + /// Folds archived timestamps. First tries a section that already 395 + /// references the worktree; falls back to a prefix match across 396 + /// the precomputed `rootCandidates` pool when the strict section 397 + /// lookup fails. Unplaceable entries log the specific IDs at info 398 + /// level so operators can grep logs. 399 + private static func foldArchived( 400 + into state: inout SidebarState, 401 + legacyArchived: [String: Date], 402 + rootCandidates: [(candidate: String, owningRoot: String)] 403 + ) { 404 + var unplacedArchivedIDs: [Worktree.ID] = [] 405 + for (rawArchivedID, archivedAt) in legacyArchived { 406 + guard let archivedWorktreeID = RepositoryPathNormalizer.normalize(rawArchivedID) else { 407 + continue 408 + } 409 + let owningRepoID = 410 + state.sections.first(where: { _, section in 411 + section.buckets.values.contains(where: { $0.items[archivedWorktreeID] != nil }) 412 + })?.key 413 + ?? repositoryID(owningWorktreeID: archivedWorktreeID, amongLegacyRoots: rootCandidates) 414 + guard let owningRepoID else { 415 + unplacedArchivedIDs.append(archivedWorktreeID) 416 + continue 417 + } 418 + // Clear the worktree from `.pinned` / `.unpinned` then 419 + // insert into `.archived` with the timestamp. Three explicit 420 + // removes beats a scan. 421 + state.remove(worktree: archivedWorktreeID, in: owningRepoID, from: .pinned) 422 + state.remove(worktree: archivedWorktreeID, in: owningRepoID, from: .unpinned) 423 + state.insert( 424 + worktree: archivedWorktreeID, 425 + in: owningRepoID, 426 + bucket: .archived, 427 + item: .init(archivedAt: archivedAt) 428 + ) 429 + } 430 + guard !unplacedArchivedIDs.isEmpty else { 431 + return 432 + } 433 + logger.info( 434 + "Dropped \(unplacedArchivedIDs.count) orphan archived worktree(s) with no matching root." 435 + ) 436 + for id in unplacedArchivedIDs { 437 + logger.info("Dropped orphan archived worktree: \(id).") 438 + } 439 + } 440 + 441 + /// Atomic write of the new nested shape. `storage.save` writes 442 + /// via temp+rename, so the file either exists completely or 443 + /// not at all. Bypass the `@Shared(.sidebar)` cache so the 444 + /// SharedKey doesn't hydrate with an empty `SidebarState()` 445 + /// before the real contents land on disk. Returns `false` and 446 + /// logs on failure so the caller can bail before touching the 447 + /// legacy sources. 448 + private static func persist(state: SidebarState, to sidebarURL: URL) -> Bool { 449 + do { 450 + @Dependency(\.settingsFileStorage) var storage 451 + let encoder = JSONEncoder() 452 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 453 + let data = try encoder.encode(state) 454 + try storage.save(data, sidebarURL) 455 + return true 456 + } catch { 457 + logger.warning("Failed to write sidebar.json during migration: \(error)") 458 + return false 459 + } 460 + } 461 + 462 + /// Pool of (candidate-prefix → owning-root) pairs used by the 463 + /// longest-prefix resolver. Covers every filesystem location a 464 + /// worktree may sit under: 465 + /// - The repo root itself (worktrees nested directly inside the 466 + /// checkout). 467 + /// - The effective worktree-base directory derived from 468 + /// `SupacodePaths.worktreeBaseDirectory(for:globalDefaultPath: 469 + /// repositoryOverridePath:)`, which routes through (in priority 470 + /// order) per-repo `supacode.json` override → global 471 + /// `defaultWorktreeBaseDirectoryPath` override → default 472 + /// `~/.supacode/repos/<lastPathComponent>/` convention. 473 + /// Per-repo overrides are read synchronously from disk via the 474 + /// `\.repositoryLocalSettingsStorage` dependency — NOT 475 + /// `@Shared(.repositorySettings(rootURL))`, whose async 476 + /// hydration path would race the one-shot migrator. Missing / 477 + /// corrupt `supacode.json` files are skipped silently; the 478 + /// default-convention candidate always emits so the convention 479 + /// fallback still works. 480 + private static func rootCandidates( 481 + legacyRoots: [String], 482 + settingsFile: SettingsFile 483 + ) -> [(candidate: String, owningRoot: String)] { 484 + @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage 485 + let globalDefaultPath = settingsFile.global.defaultWorktreeBaseDirectoryPath 486 + var candidates: [(candidate: String, owningRoot: String)] = [] 487 + var seen: Set<String> = [] 488 + for owningRoot in legacyRoots { 489 + let rootURL = URL(fileURLWithPath: owningRoot) 490 + // 1. Root itself — worktrees living directly under the 491 + // checkout (custom worktree dirs that are relative paths 492 + // resolving inside the root). 493 + if let normalisedRoot = RepositoryPathNormalizer.normalize(owningRoot), 494 + seen.insert(normalisedRoot).inserted 495 + { 496 + candidates.append((candidate: normalisedRoot, owningRoot: owningRoot)) 497 + } 498 + // 2. Per-repo override, read synchronously (no SharedKey). 499 + let perRepoOverride = loadRepositoryOverridePath( 500 + for: rootURL, 501 + storage: repositoryLocalSettingsStorage 502 + ) 503 + // 3. Effective worktree-base for this root, applying all 504 + // three layers of precedence. Emits the default 505 + // `~/.supacode/repos/<name>/` convention when no override 506 + // is configured. 507 + let worktreeBase = SupacodePaths.worktreeBaseDirectory( 508 + for: rootURL, 509 + globalDefaultPath: globalDefaultPath, 510 + repositoryOverridePath: perRepoOverride 511 + ) 512 + let worktreeBasePath = worktreeBase.path(percentEncoded: false) 513 + if let normalisedBase = RepositoryPathNormalizer.normalize(worktreeBasePath), 514 + seen.insert(normalisedBase).inserted 515 + { 516 + candidates.append((candidate: normalisedBase, owningRoot: owningRoot)) 517 + } 518 + } 519 + return candidates 520 + } 521 + 522 + /// Synchronously reads `<root>/supacode.json` via the injected 523 + /// storage dependency and returns the persisted 524 + /// `worktreeBaseDirectoryPath` override, or `nil` when the file 525 + /// is missing, unreadable, decode-fails, or doesn't set an 526 + /// override. Must not hit `@Shared(.repositorySettings(rootURL))` 527 + /// — that SharedKey hydrates asynchronously and would race the 528 + /// one-shot migrator. 529 + private static func loadRepositoryOverridePath( 530 + for rootURL: URL, 531 + storage: RepositoryLocalSettingsStorage 532 + ) -> String? { 533 + let url = SupacodePaths.repositorySettingsURL(for: rootURL) 534 + guard let data = try? storage.load(url) else { 535 + return nil 536 + } 537 + guard let settings = try? JSONDecoder().decode(RepositorySettings.self, from: data) else { 538 + return nil 539 + } 540 + return settings.worktreeBaseDirectoryPath 541 + } 542 + 543 + /// Recover the owning `Repository.ID` for a legacy flat worktree 544 + /// ID (a filesystem path) by prefix-matching against the 545 + /// precomputed candidate pool. Each entry pairs a candidate 546 + /// prefix (repo root OR a worktree-base directory) with the 547 + /// `Repository.ID` that owns it, so a pin sitting under 548 + /// `~/.supacode/repos/<name>/` still resolves to the settings 549 + /// root under `~/Developer/.../` even when the two trees share 550 + /// no common ancestor. Returns the longest-matching candidate's 551 + /// `owningRoot` so nested roots and nested bases win. Expects 552 + /// `worktreeID` and every `candidate` to already be normalised 553 + /// via `RepositoryPathNormalizer.normalize`. 554 + static func repositoryID( 555 + owningWorktreeID worktreeID: Worktree.ID, 556 + amongLegacyRoots candidates: [(candidate: String, owningRoot: String)] 557 + ) -> Repository.ID? { 558 + var bestMatch: (owningRoot: String, length: Int)? 559 + for (candidate, owningRoot) in candidates { 560 + // Strip trailing slashes before appending the directory 561 + // separator below — otherwise "/repo-a/" concatenates to 562 + // "/repo-a//" and matches nothing. 563 + let trimmedCandidate = candidate.trimmingCharacters(in: CharacterSet(charactersIn: "/")) 564 + guard !trimmedCandidate.isEmpty else { 565 + continue 566 + } 567 + let candidateWithLeadingSlash = "/" + trimmedCandidate 568 + // The worktree path should sit under the candidate's 569 + // directory; the trailing slash guards against a spurious 570 + // match where one candidate is a non-directory prefix of 571 + // another (e.g. "/tmp/rep" vs "/tmp/repo"). 572 + guard worktreeID.hasPrefix(candidateWithLeadingSlash + "/") else { 573 + continue 574 + } 575 + if candidateWithLeadingSlash.count > (bestMatch?.length ?? 0) { 576 + bestMatch = (owningRoot, candidateWithLeadingSlash.count) 577 + } 578 + } 579 + return bestMatch?.owningRoot 580 + } 581 + }
+346
supacode/Features/Repositories/BusinessLogic/SidebarState.swift
··· 1 + import Foundation 2 + import OrderedCollections 3 + 4 + /// User-curated sidebar state persisted to `~/.supacode/sidebar.json`. 5 + /// 6 + /// The shape mirrors the rendered tree: the root holds sections (one 7 + /// per repository row), each section holds buckets (pinned / 8 + /// unpinned / archived), and each bucket holds items (one per 9 + /// worktree row). Readers access everything via keyed lookup — 10 + /// `sections[repo]?.buckets[.pinned]?.items[wt]` — so callers never 11 + /// rely on bucket iteration order. `Item.archivedAt` is non-`nil` 12 + /// only inside the `.archived` bucket; the mutating API clears it 13 + /// when an item leaves `.archived`, so the field is a reliable "is 14 + /// this currently archived?" signal without a separate bucket 15 + /// check. 16 + /// 17 + /// Mutations go through typed primitives that take full coordinates 18 + /// — repo + worktree + source bucket — so every helper is O(1) by 19 + /// construction and the sidebar stays the single source of truth for 20 + /// pin/order/archive state. `move`, `insert`, `archive`, `unarchive`, 21 + /// `remove`, `reorder` cover the full mutation surface; callers 22 + /// always know the source bucket from their reducer context. 23 + nonisolated struct SidebarState: Equatable, Sendable, Codable { 24 + var schemaVersion: Int 25 + var sections: OrderedDictionary<Repository.ID, Section> 26 + var focusedWorktreeID: Worktree.ID? 27 + 28 + /// Memberwise initializer. `schemaVersion` defaults to `0`, meaning 29 + /// "not migrated yet, or migrator failed". The boot-time migrator 30 + /// is the only writer that sets it to `1`; every other writer 31 + /// (including the default `SidebarState()` path and mutations 32 + /// persisted by `SidebarKey.save`) leaves it at `0`. 33 + init( 34 + schemaVersion: Int = 0, 35 + sections: OrderedDictionary<Repository.ID, Section> = [:], 36 + focusedWorktreeID: Worktree.ID? = nil 37 + ) { 38 + self.schemaVersion = schemaVersion 39 + self.sections = sections 40 + self.focusedWorktreeID = focusedWorktreeID 41 + } 42 + 43 + private enum CodingKeys: String, CodingKey { 44 + case schemaVersion 45 + case sections 46 + case focusedWorktreeID 47 + } 48 + 49 + init(from decoder: any Decoder) throws { 50 + let container = try decoder.container(keyedBy: CodingKeys.self) 51 + // Default to `0` when the key is absent so existing 52 + // `sidebar.json` files written before `schemaVersion` existed 53 + // decode as "not migrated yet". 54 + self.schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 0 55 + self.sections = 56 + try container.decodeIfPresent( 57 + OrderedDictionary<Repository.ID, Section>.self, 58 + forKey: .sections 59 + ) ?? [:] 60 + self.focusedWorktreeID = try container.decodeIfPresent(Worktree.ID.self, forKey: .focusedWorktreeID) 61 + } 62 + 63 + func encode(to encoder: any Encoder) throws { 64 + var container = encoder.container(keyedBy: CodingKeys.self) 65 + // Always encode `schemaVersion` so the value round-trips and the 66 + // migrator can distinguish "written by migrator" from "written by 67 + // first-mutation after migrator failure". 68 + try container.encode(schemaVersion, forKey: .schemaVersion) 69 + try container.encode(sections, forKey: .sections) 70 + try container.encodeIfPresent(focusedWorktreeID, forKey: .focusedWorktreeID) 71 + } 72 + 73 + nonisolated enum BucketID: String, Codable, Hashable, Sendable { 74 + case pinned 75 + case unpinned 76 + case archived 77 + } 78 + 79 + nonisolated struct Section: Equatable, Sendable, Codable { 80 + var collapsed: Bool 81 + var buckets: OrderedDictionary<BucketID, Bucket> 82 + 83 + init( 84 + collapsed: Bool = false, 85 + buckets: OrderedDictionary<BucketID, Bucket> = [:] 86 + ) { 87 + self.collapsed = collapsed 88 + self.buckets = buckets 89 + } 90 + 91 + private enum SectionCodingKeys: String, CodingKey { 92 + case collapsed 93 + case buckets 94 + } 95 + 96 + init(from decoder: any Decoder) throws { 97 + let container = try decoder.container(keyedBy: SectionCodingKeys.self) 98 + // Default to `false` / empty when the key is absent so existing 99 + // `sidebar.json` files written before these fields became 100 + // non-optional still decode cleanly. 101 + self.collapsed = try container.decodeIfPresent(Bool.self, forKey: .collapsed) ?? false 102 + self.buckets = 103 + try container.decodeIfPresent( 104 + OrderedDictionary<BucketID, Bucket>.self, 105 + forKey: .buckets 106 + ) ?? [:] 107 + } 108 + 109 + func encode(to encoder: any Encoder) throws { 110 + var container = encoder.container(keyedBy: SectionCodingKeys.self) 111 + // Always encode `collapsed` and `buckets` so the wire format 112 + // stays exhaustive and the migrator can rely on a stable shape. 113 + try container.encode(collapsed, forKey: .collapsed) 114 + try container.encode(buckets, forKey: .buckets) 115 + } 116 + } 117 + 118 + nonisolated struct Bucket: Equatable, Sendable, Codable { 119 + var items: OrderedDictionary<Worktree.ID, Item> = [:] 120 + } 121 + 122 + nonisolated struct Item: Equatable, Sendable, Codable { 123 + /// Timestamp the worktree was archived at. Non-`nil` only inside 124 + /// the `.archived` bucket — the mutating API clears it when an 125 + /// item leaves `.archived`, so the field is a reliable "is this 126 + /// currently archived?" signal without a bucket check. 127 + var archivedAt: Date? 128 + } 129 + 130 + /// Flat reference to an archived worktree: the owning repo, the 131 + /// worktree ID, and the timestamp it was archived at. Used by the 132 + /// `archivedWorktrees` accessor so callers can consume the fan-out 133 + /// via property access rather than tuple destructuring. 134 + nonisolated struct ArchivedWorktreeRef: Equatable, Sendable { 135 + let repositoryID: Repository.ID 136 + let worktreeID: Worktree.ID 137 + let archivedAt: Date 138 + } 139 + } 140 + 141 + // MARK: - Read-side accessors. 142 + 143 + nonisolated extension SidebarState { 144 + /// Flat view over every archived worktree across every section. 145 + /// The only reader that genuinely needs a fan-out iterator is the 146 + /// auto-delete sweep (and the archived-worktrees detail view), 147 + /// which can't know the owning repo up front. Every other reader 148 + /// should reach through `sections[repoID]?.buckets[bucket]?.items[wid]?` 149 + /// directly. 150 + /// 151 + /// Iteration follows `sections` insertion order, then item 152 + /// insertion order inside `.archived`. 153 + var archivedWorktrees: [ArchivedWorktreeRef] { 154 + var result: [ArchivedWorktreeRef] = [] 155 + for (repoID, section) in sections { 156 + guard let archived = section.buckets[.archived] else { 157 + continue 158 + } 159 + for (worktreeID, item) in archived.items { 160 + if let archivedAt = item.archivedAt { 161 + result.append( 162 + ArchivedWorktreeRef( 163 + repositoryID: repoID, 164 + worktreeID: worktreeID, 165 + archivedAt: archivedAt 166 + ) 167 + ) 168 + } 169 + } 170 + } 171 + return result 172 + } 173 + 174 + /// Bucket that currently contains `worktreeID` in `repositoryID`, 175 + /// or `nil` when the worktree isn't curated in any bucket. Used 176 + /// by reducer actions that need to pass `from:` to `move` or 177 + /// `archive` but only know the repo + worktree from their action 178 + /// payload. O(buckets) = O(3); cheaper than any scan. 179 + func currentBucket(of worktreeID: Worktree.ID, in repositoryID: Repository.ID) -> BucketID? { 180 + guard let section = sections[repositoryID] else { 181 + return nil 182 + } 183 + for (bucketID, bucket) in section.buckets where bucket.items[worktreeID] != nil { 184 + return bucketID 185 + } 186 + return nil 187 + } 188 + } 189 + 190 + // MARK: - Mutations. 191 + 192 + nonisolated extension SidebarState { 193 + /// Move `worktreeID` from `from` to `to` inside `repositoryID`, 194 + /// preserving the existing `Item` payload. Clears `archivedAt` 195 + /// when `to != .archived`. `position` is the insertion index 196 + /// inside `to` (default `0` = top of bucket; `nil` = append). 197 + /// No-op when the item isn't in `from`. 198 + mutating func move( 199 + worktree worktreeID: Worktree.ID, 200 + in repositoryID: Repository.ID, 201 + from: BucketID, 202 + to destination: BucketID, 203 + position: Int? = 0 204 + ) { 205 + guard var section = sections[repositoryID] else { 206 + return 207 + } 208 + guard var item = section.buckets[from]?.items.removeValue(forKey: worktreeID) else { 209 + return 210 + } 211 + if destination != .archived { 212 + item.archivedAt = nil 213 + } 214 + var bucket = section.buckets[destination] ?? .init() 215 + insert(item: item, for: worktreeID, into: &bucket, position: position) 216 + section.buckets[destination] = bucket 217 + sections[repositoryID] = section 218 + } 219 + 220 + /// Insert a fresh `Item` into the given bucket at `position`. 221 + /// Used by the reducer's seed pass when a newly-discovered live 222 + /// worktree first appears, so every rendered worktree has a 223 + /// bucketed entry by the time the view reads it. 224 + mutating func insert( 225 + worktree worktreeID: Worktree.ID, 226 + in repositoryID: Repository.ID, 227 + bucket bucketID: BucketID, 228 + item: Item = .init(), 229 + position: Int? = nil 230 + ) { 231 + var section = sections[repositoryID] ?? .init() 232 + var bucket = section.buckets[bucketID] ?? .init() 233 + insert(item: item, for: worktreeID, into: &bucket, position: position) 234 + section.buckets[bucketID] = bucket 235 + sections[repositoryID] = section 236 + } 237 + 238 + /// Archive `worktreeID`: drop from `from`, insert into `.archived` 239 + /// at the tail with the given timestamp. Materialises the section 240 + /// and the archived bucket when missing so a late-arriving 241 + /// archive action lands even if the pruner's seed pass hasn't 242 + /// run yet for this repo. 243 + mutating func archive( 244 + worktree worktreeID: Worktree.ID, 245 + in repositoryID: Repository.ID, 246 + from: BucketID, 247 + at timestamp: Date 248 + ) { 249 + var section = sections[repositoryID] ?? .init() 250 + section.buckets[from]?.items.removeValue(forKey: worktreeID) 251 + var archived = section.buckets[.archived] ?? .init() 252 + archived.items[worktreeID] = .init(archivedAt: timestamp) 253 + section.buckets[.archived] = archived 254 + sections[repositoryID] = section 255 + } 256 + 257 + /// Unarchive `worktreeID`: drop from `.archived` and reinsert at 258 + /// the top of `.unpinned`. Clears `archivedAt`. No-op when the 259 + /// worktree isn't currently archived in this section. 260 + mutating func unarchive(worktree worktreeID: Worktree.ID, in repositoryID: Repository.ID) { 261 + move(worktree: worktreeID, in: repositoryID, from: .archived, to: .unpinned, position: 0) 262 + } 263 + 264 + /// Remove `worktreeID` from `bucketID` of `repositoryID`. 265 + /// No-op when the section or bucket or worktree is absent. Used 266 + /// by callers that know the source bucket. 267 + mutating func remove( 268 + worktree worktreeID: Worktree.ID, 269 + in repositoryID: Repository.ID, 270 + from bucketID: BucketID 271 + ) { 272 + sections[repositoryID]?.buckets[bucketID]?.items.removeValue(forKey: worktreeID) 273 + } 274 + 275 + /// Remove `worktreeID` from every bucket of `repositoryID`. Used 276 + /// by the delete flow (the worktree is going away entirely, so 277 + /// we don't need to know which bucket currently owns it). O(1) 278 + /// — exactly three bucket subscripts, no scan. 279 + mutating func removeAnywhere(worktree worktreeID: Worktree.ID, in repositoryID: Repository.ID) { 280 + guard sections[repositoryID] != nil else { 281 + return 282 + } 283 + sections[repositoryID]?.buckets[.pinned]?.items.removeValue(forKey: worktreeID) 284 + sections[repositoryID]?.buckets[.unpinned]?.items.removeValue(forKey: worktreeID) 285 + sections[repositoryID]?.buckets[.archived]?.items.removeValue(forKey: worktreeID) 286 + } 287 + 288 + /// Reorder `bucketID`'s items in `repositoryID` to exactly 289 + /// `reorderedIDs`, preserving item payloads. Items in 290 + /// `reorderedIDs` that don't currently live in this bucket are 291 + /// ignored; items outside `reorderedIDs` keep their current 292 + /// relative position after the reordered run. Other buckets 293 + /// untouched. 294 + mutating func reorder( 295 + bucket bucketID: BucketID, 296 + in repositoryID: Repository.ID, 297 + to reorderedIDs: [Worktree.ID] 298 + ) { 299 + guard var section = sections[repositoryID], var bucket = section.buckets[bucketID] else { 300 + return 301 + } 302 + let reorderedSet = Set(reorderedIDs) 303 + var rebuilt: OrderedDictionary<Worktree.ID, Item> = [:] 304 + var reorderedInserted = false 305 + for (worktreeID, item) in bucket.items { 306 + if reorderedSet.contains(worktreeID) { 307 + if !reorderedInserted { 308 + for id in reorderedIDs { 309 + if let existing = bucket.items[id] { 310 + rebuilt[id] = existing 311 + } 312 + } 313 + reorderedInserted = true 314 + } 315 + continue 316 + } 317 + rebuilt[worktreeID] = item 318 + } 319 + if !reorderedInserted { 320 + for id in reorderedIDs { 321 + if let existing = bucket.items[id] { 322 + rebuilt[id] = existing 323 + } 324 + } 325 + } 326 + bucket.items = rebuilt 327 + section.buckets[bucketID] = bucket 328 + sections[repositoryID] = section 329 + } 330 + 331 + /// Shared insertion helper — clamps `position` to the current 332 + /// item count and falls back to append when `position` is `nil` 333 + /// or out of range. 334 + private func insert( 335 + item: Item, 336 + for worktreeID: Worktree.ID, 337 + into bucket: inout Bucket, 338 + position: Int? 339 + ) { 340 + if let position, position < bucket.items.count { 341 + bucket.items.updateValue(item, forKey: worktreeID, insertingAt: position) 342 + } else { 343 + bucket.items[worktreeID] = item 344 + } 345 + } 346 + }
+369 -497
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 2 2 import ComposableArchitecture 3 3 import Foundation 4 4 import IdentifiedCollections 5 + import OrderedCollections 5 6 import PostHog 6 7 import SupacodeSettingsShared 7 8 import SwiftUI ··· 67 68 struct State: Equatable { 68 69 var repositories: IdentifiedArrayOf<Repository> = [] 69 70 var repositoryRoots: [URL] = [] 70 - var repositoryOrderIDs: [Repository.ID] = [] 71 71 var loadFailuresByID: [Repository.ID: String] = [:] 72 72 var selection: SidebarSelection? 73 73 var worktreeInfoByID: [Worktree.ID: WorktreeInfoEntry] = [:] 74 - var worktreeOrderByRepository: [Repository.ID: [Worktree.ID]] = [:] 75 74 var isOpenPanelPresented = false 76 75 var isInitialLoadComplete = false 77 76 var pendingWorktrees: [PendingWorktree] = [] ··· 82 81 var deleteScriptWorktreeIDs: Set<Worktree.ID> = [] 83 82 var deletingWorktreeIDs: Set<Worktree.ID> = [] 84 83 var removingRepositoryIDs: Set<Repository.ID> = [] 85 - var pinnedWorktreeIDs: [Worktree.ID] = [] 86 - var archivedWorktreeDates: [Worktree.ID: Date] = [:] 87 84 var autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? 88 85 var mergedWorktreeAction: MergedWorktreeAction? 89 86 var moveNotifiedWorktreeToTop = true 90 - var lastFocusedWorktreeID: Worktree.ID? 91 87 var shouldRestoreLastFocusedWorktree = false 92 88 var shouldSelectFirstAfterReload = false 93 89 var isRefreshingWorktrees = false ··· 99 95 var sidebarSelectedWorktreeIDs: Set<Worktree.ID> = [] 100 96 var nextPendingSidebarRevealID = 0 101 97 var pendingSidebarReveal: PendingSidebarReveal? 102 - @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] 98 + /// Single source of truth for all user-curated sidebar state — 99 + /// section order / collapse / pin / unpin / archive / focused 100 + /// worktree — persisted to `~/.supacode/sidebar.json`. Replaces 101 + /// the six legacy slices (pin / archive / repo order / worktree 102 + /// order / focus / collapsed). All co-mutating actions fold 103 + /// through `$sidebar.withLock` so the SharedKey emits a single 104 + /// atomic file update per reducer action. 105 + @Shared(.sidebar) var sidebar: SidebarState 103 106 @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? 104 107 @Presents var alert: AlertState<Alert>? 105 108 } ··· 131 134 case task 132 135 case setOpenPanelPresented(Bool) 133 136 case loadPersistedRepositories 134 - case pinnedWorktreeIDsLoaded([Worktree.ID]) 135 - case archivedWorktreeDatesLoaded([Worktree.ID: Date]) 136 - case repositoryOrderIDsLoaded([Repository.ID]) 137 - case worktreeOrderByRepositoryLoaded([Repository.ID: [Worktree.ID]]) 138 - case lastFocusedWorktreeIDLoaded(Worktree.ID?) 139 137 case refreshWorktrees 140 138 case reloadRepositories(animated: Bool) 141 139 case repositoriesLoaded([Repository], failures: [LoadFailure], roots: [URL], animated: Bool) ··· 272 270 } 273 271 274 272 private struct ApplyRepositoriesResult { 275 - let didPrunePinned: Bool 276 - // Auto-persisted via `@Shared`; tracked for consistency but not consumed in effect dispatch. 277 - let didPruneCollapsedRepositoryIDs: Bool 278 - let didPruneRepositoryOrder: Bool 279 - let didPruneWorktreeOrder: Bool 280 273 let didPruneArchivedWorktreeIDs: Bool 281 274 } 282 275 ··· 329 322 Reduce { state, action in 330 323 switch action { 331 324 case .task: 332 - return .run { send in 333 - let pinned = await repositoryPersistence.loadPinnedWorktreeIDs() 334 - let archived = await repositoryPersistence.loadArchivedWorktreeDates() 335 - let lastFocused = await repositoryPersistence.loadLastFocusedWorktreeID() 336 - let repositoryOrderIDs = await repositoryPersistence.loadRepositoryOrderIDs() 337 - let worktreeOrderByRepository = 338 - await repositoryPersistence.loadWorktreeOrderByRepository() 339 - await send(.pinnedWorktreeIDsLoaded(pinned)) 340 - await send(.archivedWorktreeDatesLoaded(archived)) 341 - await send(.repositoryOrderIDsLoaded(repositoryOrderIDs)) 342 - await send(.worktreeOrderByRepositoryLoaded(worktreeOrderByRepository)) 343 - await send(.lastFocusedWorktreeIDLoaded(lastFocused)) 344 - await send(.loadPersistedRepositories) 345 - } 346 - 347 - case .pinnedWorktreeIDsLoaded(let pinnedWorktreeIDs): 348 - state.pinnedWorktreeIDs = pinnedWorktreeIDs 349 - return .none 350 - 351 - case .archivedWorktreeDatesLoaded(let archivedWorktreeDates): 352 - state.archivedWorktreeDates = archivedWorktreeDates 353 - return .none 354 - 355 - case .repositoryOrderIDsLoaded(let repositoryOrderIDs): 356 - state.repositoryOrderIDs = repositoryOrderIDs 357 - return .none 358 - 359 - case .worktreeOrderByRepositoryLoaded(let worktreeOrderByRepository): 360 - state.worktreeOrderByRepository = worktreeOrderByRepository 361 - return .none 362 - 363 - case .lastFocusedWorktreeIDLoaded(let lastFocusedWorktreeID): 364 - state.lastFocusedWorktreeID = lastFocusedWorktreeID 365 - state.shouldRestoreLastFocusedWorktree = true 366 - return .none 325 + // `sidebar` is already hydrated from `sidebar.json` (loaded 326 + // synchronously by the SharedKey when State is constructed), 327 + // so `.task` has no persistence fan-out left — it just flags 328 + // the focus restore and kicks off the repository load. 329 + state.shouldRestoreLastFocusedWorktree = state.sidebar.focusedWorktreeID != nil 330 + return .send(.loadPersistedRepositories) 367 331 368 332 case .setOpenPanelPresented(let isPresented): 369 333 state.isOpenPanelPresented = isPresented ··· 407 371 let previousSelectedWorktree = state.worktree(for: previousSelection) 408 372 let incomingRepositories = IdentifiedArray(uniqueElements: repositories) 409 373 let repositoriesChanged = incomingRepositories != state.repositories 410 - let applyResult = applyRepositories( 374 + _ = applyRepositories( 411 375 repositories, 412 376 roots: roots, 413 377 shouldPruneArchivedWorktreeIDs: failures.isEmpty, ··· 433 397 if selectionChanged { 434 398 allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 435 399 } 436 - if applyResult.didPrunePinned { 437 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 438 - allEffects.append( 439 - .run { _ in 440 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 441 - }) 442 - } 443 - if applyResult.didPruneRepositoryOrder { 444 - let repositoryOrderIDs = state.repositoryOrderIDs 445 - allEffects.append( 446 - .run { _ in 447 - await repositoryPersistence.saveRepositoryOrderIDs(repositoryOrderIDs) 448 - }) 449 - } 450 - if applyResult.didPruneWorktreeOrder { 451 - let worktreeOrderByRepository = state.worktreeOrderByRepository 452 - allEffects.append( 453 - .run { _ in 454 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 455 - }) 456 - } 457 - if applyResult.didPruneArchivedWorktreeIDs { 458 - let archivedWorktreeDates = state.archivedWorktreeDates 459 - allEffects.append( 460 - .run { _ in 461 - await repositoryPersistence.saveArchivedWorktreeDates(archivedWorktreeDates) 462 - } 463 - ) 464 - } 400 + // The sidebar reconciler (`reconcileSidebarState`) already 401 + // flushed any sidebar mutations through `$sidebar.withLock`, 402 + // so no per-slice save effects are needed here — the SharedKey 403 + // writes `sidebar.json` atomically. 465 404 if state.autoDeleteArchivedWorktreesAfterDays != nil { 466 405 allEffects.append(.send(.autoDeleteExpiredArchivedWorktrees)) 467 406 } ··· 505 444 state.isRefreshingWorktrees = false 506 445 let previousSelection = state.selectedWorktreeID 507 446 let previousSelectedWorktree = state.worktree(for: previousSelection) 508 - let applyResult = applyRepositories( 447 + _ = applyRepositories( 509 448 repositories, 510 449 roots: roots, 511 450 shouldPruneArchivedWorktreeIDs: failures.isEmpty, ··· 537 476 if selectionChanged { 538 477 allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 539 478 } 540 - if applyResult.didPrunePinned { 541 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 542 - allEffects.append( 543 - .run { _ in 544 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 545 - }) 546 - } 547 - if applyResult.didPruneRepositoryOrder { 548 - let repositoryOrderIDs = state.repositoryOrderIDs 549 - allEffects.append( 550 - .run { _ in 551 - await repositoryPersistence.saveRepositoryOrderIDs(repositoryOrderIDs) 552 - }) 553 - } 554 - if applyResult.didPruneWorktreeOrder { 555 - let worktreeOrderByRepository = state.worktreeOrderByRepository 556 - allEffects.append( 557 - .run { _ in 558 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 559 - }) 560 - } 561 - if applyResult.didPruneArchivedWorktreeIDs { 562 - let archivedWorktreeDates = state.archivedWorktreeDates 563 - allEffects.append( 564 - .run { _ in 565 - await repositoryPersistence.saveArchivedWorktreeDates(archivedWorktreeDates) 566 - } 567 - ) 568 - } 479 + // See `.repositoriesLoaded` above for why no per-slice save 480 + // effects run here — sidebar mutations already flushed. 569 481 if state.autoDeleteArchivedWorktreesAfterDays != nil { 570 482 allEffects.append(.send(.autoDeleteExpiredArchivedWorktrees)) 571 483 } ··· 579 491 ) 580 492 581 493 case .repositoryExpansionChanged(let repositoryID, let isExpanded): 582 - state.$collapsedRepositoryIDs.withLock { collapsedRepositoryIDs in 583 - if isExpanded { 584 - collapsedRepositoryIDs.removeAll { $0 == repositoryID } 585 - } else if !collapsedRepositoryIDs.contains(repositoryID) { 586 - collapsedRepositoryIDs.append(repositoryID) 587 - } 588 - collapsedRepositoryIDs.sort() 494 + state.$sidebar.withLock { sidebar in 495 + // Writing the explicit bit (true / false) instead of 496 + // adding/removing from a set lets future default-flip 497 + // logic distinguish "user expanded" from "never touched". 498 + sidebar.sections[repositoryID, default: .init()].collapsed = !isExpanded 589 499 } 590 500 return .none 591 501 ··· 623 533 guard let worktreeID = state.selectedWorktreeID, 624 534 let repositoryID = state.repositoryID(containing: worktreeID) 625 535 else { return .none } 626 - state.$collapsedRepositoryIDs.withLock { 627 - $0.removeAll { $0 == repositoryID } 536 + state.$sidebar.withLock { sidebar in 537 + sidebar.sections[repositoryID, default: .init()].collapsed = false 628 538 } 629 539 state.nextPendingSidebarRevealID += 1 630 540 state.pendingSidebarReveal = .init(id: state.nextPendingSidebarRevealID, worktreeID: worktreeID) ··· 1252 1162 if selectionChanged { 1253 1163 effects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 1254 1164 } 1255 - if cleanup.didUpdatePinned { 1256 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 1257 - effects.append( 1258 - .run { _ in 1259 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1260 - } 1261 - ) 1262 - } 1263 - if cleanup.didUpdateOrder { 1264 - let worktreeOrderByRepository = state.worktreeOrderByRepository 1265 - effects.append( 1266 - .run { _ in 1267 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 1268 - } 1269 - ) 1270 - } 1165 + // Sidebar-state mutations in `cleanupWorktreeState` already 1166 + // went through `$sidebar.withLock`, so no per-slice save 1167 + // effects are needed here. 1271 1168 if let cleanupWorktree = cleanup.worktree { 1272 1169 let repositoryRootURL = cleanupWorktree.repositoryRootURL 1273 1170 effects.append( ··· 1490 1387 selectionWasRemoved 1491 1388 ? nextWorktreeID(afterRemoving: worktree, in: repository, state: state) 1492 1389 : nil 1493 - var didUpdateWorktreeOrder = false 1494 - let wasPinned = state.pinnedWorktreeIDs.contains(worktreeID) 1495 1390 withAnimation { 1496 1391 state.alert = nil 1497 - state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 1498 - if var order = state.worktreeOrderByRepository[repositoryID] { 1499 - order.removeAll { $0 == worktreeID } 1500 - if order.isEmpty { 1501 - state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 1502 - } else { 1503 - state.worktreeOrderByRepository[repositoryID] = order 1504 - } 1505 - didUpdateWorktreeOrder = true 1392 + // Drop the item from its current pinned/unpinned bucket 1393 + // and insert into `.archived` with the timestamp. The 1394 + // seed pass in `reconcileSidebarState` guarantees every 1395 + // live non-main worktree lives in either `.pinned` or 1396 + // `.unpinned` before this runs. 1397 + state.$sidebar.withLock { sidebar in 1398 + let from = sidebar.currentBucket(of: worktreeID, in: repositoryID) ?? .unpinned 1399 + sidebar.archive(worktree: worktreeID, in: repositoryID, from: from, at: now) 1506 1400 } 1507 - state.archivedWorktreeDates[worktreeID] = now 1508 1401 if selectionWasRemoved { 1509 1402 let nextWorktreeID = nextSelection ?? firstAvailableWorktreeID(in: repositoryID, state: state) 1510 1403 state.selection = nextWorktreeID.map(SidebarSelection.worktree) 1511 1404 } 1512 1405 } 1513 - let archivedWorktreeDates = state.archivedWorktreeDates 1514 1406 let repositories = state.repositories 1515 1407 let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 1516 1408 let selectionChanged = selectionDidChange( ··· 1520 1412 selectedWorktree: selectedWorktree 1521 1413 ) 1522 1414 var effects: [Effect<Action>] = [ 1523 - .send(.delegate(.repositoriesChanged(repositories))), 1524 - .run { _ in 1525 - await repositoryPersistence.saveArchivedWorktreeDates(archivedWorktreeDates) 1526 - }, 1415 + .send(.delegate(.repositoriesChanged(repositories))) 1527 1416 ] 1528 - if wasPinned { 1529 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 1530 - effects.append( 1531 - .run { _ in 1532 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1533 - } 1534 - ) 1535 - } 1536 - if didUpdateWorktreeOrder { 1537 - let worktreeOrderByRepository = state.worktreeOrderByRepository 1538 - effects.append( 1539 - .run { _ in 1540 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 1541 - } 1542 - ) 1543 - } 1544 1417 if selectionChanged { 1545 1418 effects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 1546 1419 } 1547 1420 return .merge(effects) 1548 1421 1549 1422 case .unarchiveWorktree(let worktreeID): 1550 - if !state.isWorktreeArchived(worktreeID) { 1423 + guard let repositoryID = state.repositoryID(containing: worktreeID), 1424 + state.sidebar.sections[repositoryID]?.buckets[.archived]?.items[worktreeID] != nil 1425 + else { 1551 1426 return .none 1552 1427 } 1553 - _ = withAnimation { 1554 - state.archivedWorktreeDates.removeValue(forKey: worktreeID) 1428 + withAnimation { 1429 + state.$sidebar.withLock { sidebar in 1430 + sidebar.unarchive(worktree: worktreeID, in: repositoryID) 1431 + } 1555 1432 } 1556 - let archivedWorktreeDates = state.archivedWorktreeDates 1557 1433 let repositories = state.repositories 1558 - return .merge( 1559 - .send(.delegate(.repositoriesChanged(repositories))), 1560 - .run { _ in 1561 - await repositoryPersistence.saveArchivedWorktreeDates(archivedWorktreeDates) 1562 - } 1563 - ) 1434 + return .send(.delegate(.repositoriesChanged(repositories))) 1564 1435 1565 1436 case .requestDeleteWorktree(let worktreeID, let repositoryID): 1566 1437 if state.removingRepositoryIDs.contains(repositoryID) { ··· 1769 1640 analyticsClient.capture("worktree_deleted", nil) 1770 1641 let previousSelection = state.selectedWorktreeID 1771 1642 let previousSelectedWorktree = state.worktree(for: previousSelection) 1772 - let wasPinned = state.pinnedWorktreeIDs.contains(worktreeID) 1773 - var didUpdateWorktreeOrder = false 1774 - let wasArchived = state.isWorktreeArchived(worktreeID) 1775 1643 withAnimation(.easeOut(duration: 0.2)) { 1776 1644 state.deletingWorktreeIDs.remove(worktreeID) 1777 1645 state.deleteScriptWorktreeIDs.remove(worktreeID) ··· 1780 1648 state.pendingSetupScriptWorktreeIDs.remove(worktreeID) 1781 1649 state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) 1782 1650 state.worktreeInfoByID.removeValue(forKey: worktreeID) 1783 - state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 1784 - state.archivedWorktreeDates.removeValue(forKey: worktreeID) 1785 - if var order = state.worktreeOrderByRepository[repositoryID] { 1786 - order.removeAll { $0 == worktreeID } 1787 - if order.isEmpty { 1788 - state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 1789 - } else { 1790 - state.worktreeOrderByRepository[repositoryID] = order 1791 - } 1792 - didUpdateWorktreeOrder = true 1651 + // Drop the worktree from every bucket in its section — 1652 + // the worktree is going away entirely so the bucket it 1653 + // currently lives in doesn't matter. 1654 + state.$sidebar.withLock { sidebar in 1655 + sidebar.removeAnywhere(worktree: worktreeID, in: repositoryID) 1793 1656 } 1794 1657 _ = removeWorktree(worktreeID, repositoryID: repositoryID, state: &state) 1795 1658 let selectionNeedsUpdate = state.selection == .worktree(worktreeID) ··· 1813 1676 if selectionChanged { 1814 1677 immediateEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 1815 1678 } 1816 - var followupEffects: [Effect<Action>] = [ 1679 + let followupEffects: [Effect<Action>] = [ 1817 1680 roots.isEmpty ? .none : .send(.reloadRepositories(animated: true)) 1818 1681 ] 1819 - if wasPinned { 1820 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 1821 - followupEffects.append( 1822 - .run { _ in 1823 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1824 - } 1825 - ) 1826 - } 1827 - if wasArchived { 1828 - let archivedWorktreeDates = state.archivedWorktreeDates 1829 - followupEffects.append( 1830 - .run { _ in 1831 - await repositoryPersistence.saveArchivedWorktreeDates(archivedWorktreeDates) 1832 - } 1833 - ) 1834 - } 1835 - if didUpdateWorktreeOrder { 1836 - let worktreeOrderByRepository = state.worktreeOrderByRepository 1837 - followupEffects.append( 1838 - .run { _ in 1839 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 1840 - } 1841 - ) 1842 - } 1843 1682 return .concatenate( 1844 1683 .merge(immediateEffects), 1845 1684 .merge(followupEffects) ··· 1847 1686 1848 1687 case .repositoriesMoved(let offsets, let destination): 1849 1688 var ordered = state.orderedRepositoryIDs() 1689 + guard !offsets.isEmpty, ordered.indices.contains(offsets.min() ?? 0), 1690 + destination <= ordered.count 1691 + else { return .none } 1850 1692 ordered.move(fromOffsets: offsets, toOffset: destination) 1851 1693 withAnimation(.snappy(duration: 0.2)) { 1852 - state.repositoryOrderIDs = ordered 1853 - } 1854 - let repositoryOrderIDs = state.repositoryOrderIDs 1855 - return .run { _ in 1856 - await repositoryPersistence.saveRepositoryOrderIDs(repositoryOrderIDs) 1694 + state.$sidebar.withLock { sidebar in 1695 + var reordered: OrderedDictionary<Repository.ID, SidebarState.Section> = [:] 1696 + for id in ordered { 1697 + reordered[id] = sidebar.sections[id] ?? .init() 1698 + } 1699 + // Sections for repos still loading / not yet seen are 1700 + // reliably absent from `ordered`; append them in their 1701 + // original relative order so a live-row reorder doesn't 1702 + // silently reshuffle curation on them. 1703 + for (id, section) in sidebar.sections where reordered[id] == nil { 1704 + reordered[id] = section 1705 + } 1706 + sidebar.sections = reordered 1707 + } 1857 1708 } 1709 + return .none 1858 1710 1859 1711 case .pinnedWorktreesMoved(let repositoryID, let offsets, let destination): 1860 1712 guard let repository = state.repositories[id: repositoryID] else { return .none } ··· 1863 1715 var reordered = currentPinned 1864 1716 reordered.move(fromOffsets: offsets, toOffset: destination) 1865 1717 withAnimation(.snappy(duration: 0.2)) { 1866 - state.pinnedWorktreeIDs = state.replacingPinnedWorktreeIDs( 1867 - in: repository, 1868 - with: reordered 1869 - ) 1718 + state.$sidebar.withLock { sidebar in 1719 + sidebar.reorder(bucket: .pinned, in: repositoryID, to: reordered) 1720 + } 1870 1721 } 1871 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 1872 - return .run { _ in 1873 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1874 - } 1722 + return .none 1875 1723 1876 1724 case .unpinnedWorktreesMoved(let repositoryID, let offsets, let destination): 1877 1725 guard let repository = state.repositories[id: repositoryID] else { return .none } ··· 1880 1728 var reordered = currentUnpinned 1881 1729 reordered.move(fromOffsets: offsets, toOffset: destination) 1882 1730 withAnimation(.snappy(duration: 0.2)) { 1883 - state.worktreeOrderByRepository[repositoryID] = reordered 1731 + state.$sidebar.withLock { sidebar in 1732 + sidebar.reorder(bucket: .unpinned, in: repositoryID, to: reordered) 1733 + } 1884 1734 } 1885 - let worktreeOrderByRepository = state.worktreeOrderByRepository 1886 - return .run { _ in 1887 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 1888 - } 1735 + return .none 1889 1736 1890 1737 case .deleteWorktreeFailed(let message, let worktreeID): 1891 1738 state.deletingWorktreeIDs.remove(worktreeID) ··· 1967 1814 ) 1968 1815 1969 1816 case .pinWorktree(let worktreeID): 1970 - if let worktree = state.worktree(for: worktreeID), state.isMainWorktree(worktree) { 1971 - let wasPinned = state.pinnedWorktreeIDs.contains(worktreeID) 1972 - state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 1973 - var didUpdateWorktreeOrder = false 1974 - if let repositoryID = state.repositoryID(containing: worktreeID), 1975 - var order = state.worktreeOrderByRepository[repositoryID] 1976 - { 1977 - order.removeAll { $0 == worktreeID } 1978 - if order.isEmpty { 1979 - state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 1980 - } else { 1981 - state.worktreeOrderByRepository[repositoryID] = order 1982 - } 1983 - didUpdateWorktreeOrder = true 1984 - } 1985 - var effects: [Effect<Action>] = [] 1986 - if wasPinned { 1987 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 1988 - effects.append( 1989 - .run { _ in 1990 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1991 - } 1992 - ) 1993 - } 1994 - if didUpdateWorktreeOrder { 1995 - let worktreeOrderByRepository = state.worktreeOrderByRepository 1996 - effects.append( 1997 - .run { _ in 1998 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 1999 - } 2000 - ) 2001 - } 2002 - return .merge(effects) 1817 + // Main worktrees never appear in any sidebar bucket (the 1818 + // seed pass skips them), so pinning one is a no-op. 1819 + guard let worktree = state.worktree(for: worktreeID), 1820 + !state.isMainWorktree(worktree), 1821 + let repositoryID = state.repositoryID(containing: worktreeID) 1822 + else { 1823 + return .none 2003 1824 } 2004 1825 analyticsClient.capture("worktree_pinned", nil) 2005 - state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 2006 - state.pinnedWorktreeIDs.insert(worktreeID, at: 0) 2007 - var didUpdateWorktreeOrder = false 2008 - if let repositoryID = state.repositoryID(containing: worktreeID), 2009 - var order = state.worktreeOrderByRepository[repositoryID] 2010 - { 2011 - order.removeAll { $0 == worktreeID } 2012 - if order.isEmpty { 2013 - state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 2014 - } else { 2015 - state.worktreeOrderByRepository[repositoryID] = order 2016 - } 2017 - didUpdateWorktreeOrder = true 2018 - } 2019 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 2020 - var effects: [Effect<Action>] = [ 2021 - .run { _ in 2022 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 2023 - } 2024 - ] 2025 - if didUpdateWorktreeOrder { 2026 - let worktreeOrderByRepository = state.worktreeOrderByRepository 2027 - effects.append( 2028 - .run { _ in 2029 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 2030 - } 1826 + state.$sidebar.withLock { sidebar in 1827 + // The seed invariant puts every non-main worktree into 1828 + // either `.pinned` or `.unpinned`. A second click on an 1829 + // already-pinned row reorders it to the top. 1830 + let from = sidebar.currentBucket(of: worktreeID, in: repositoryID) ?? .unpinned 1831 + sidebar.move( 1832 + worktree: worktreeID, 1833 + in: repositoryID, 1834 + from: from, 1835 + to: .pinned, 1836 + position: 0 2031 1837 ) 2032 1838 } 2033 - return .merge(effects) 1839 + return .none 2034 1840 2035 1841 case .unpinWorktree(let worktreeID): 2036 - analyticsClient.capture("worktree_unpinned", nil) 2037 - state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 2038 - var didUpdateWorktreeOrder = false 2039 - if let repositoryID = state.repositoryID(containing: worktreeID) { 2040 - var order = state.worktreeOrderByRepository[repositoryID] ?? [] 2041 - order.removeAll { $0 == worktreeID } 2042 - order.insert(worktreeID, at: 0) 2043 - state.worktreeOrderByRepository[repositoryID] = order 2044 - didUpdateWorktreeOrder = true 1842 + guard let repositoryID = state.repositoryID(containing: worktreeID) else { 1843 + return .none 2045 1844 } 2046 - let pinnedWorktreeIDs = state.pinnedWorktreeIDs 2047 - var effects: [Effect<Action>] = [ 2048 - .run { _ in 2049 - await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 2050 - } 2051 - ] 2052 - if didUpdateWorktreeOrder { 2053 - let worktreeOrderByRepository = state.worktreeOrderByRepository 2054 - effects.append( 2055 - .run { _ in 2056 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 2057 - } 1845 + analyticsClient.capture("worktree_unpinned", nil) 1846 + state.$sidebar.withLock { sidebar in 1847 + sidebar.move( 1848 + worktree: worktreeID, 1849 + in: repositoryID, 1850 + from: .pinned, 1851 + to: .unpinned, 1852 + position: 0 2058 1853 ) 2059 1854 } 2060 - return .merge(effects) 1855 + return .none 2061 1856 2062 1857 case .presentAlert(let title, let message): 2063 1858 state.alert = messageAlert(title: title, message: message) ··· 2113 1908 return .none 2114 1909 } 2115 1910 2116 - var effects: [Effect<Action>] = [] 2117 - 2118 1911 if state.moveNotifiedWorktreeToTop, !state.isMainWorktree(worktree), !state.isWorktreePinned(worktree) { 2119 1912 let reordered = reorderedUnpinnedWorktreeIDs( 2120 1913 for: worktreeID, 2121 1914 in: repository, 2122 1915 state: state 2123 1916 ) 2124 - if state.worktreeOrderByRepository[repositoryID] != reordered { 1917 + // Only reorder when the bumped worktree currently lives in 1918 + // (or is about to land in) the unpinned bucket — pinned 1919 + // rows live in `.pinned` and should not be perturbed by 1920 + // notification arrivals on a sibling. 1921 + let currentUnpinned = Array( 1922 + state.sidebar.sections[repositoryID]?.buckets[.unpinned]?.items.keys ?? [] 1923 + ) 1924 + if currentUnpinned != reordered { 2125 1925 withAnimation(.snappy(duration: 0.2)) { 2126 - state.worktreeOrderByRepository[repositoryID] = reordered 2127 - } 2128 - let worktreeOrderByRepository = state.worktreeOrderByRepository 2129 - effects.append( 2130 - .run { _ in 2131 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 1926 + state.$sidebar.withLock { sidebar in 1927 + sidebar.reorder(bucket: .unpinned, in: repositoryID, to: reordered) 2132 1928 } 2133 - ) 1929 + } 2134 1930 } 2135 1931 } 2136 1932 2137 - if effects.isEmpty { 2138 - return .none 2139 - } 2140 - return .merge(effects) 1933 + return .none 2141 1934 2142 1935 case .worktreeInfoEvent(let event): 2143 1936 switch event { ··· 2707 2500 guard let period = state.autoDeleteArchivedWorktreesAfterDays else { return .none } 2708 2501 let cutoff = now.addingTimeInterval(-Double(period.rawValue) * secondsPerDay) 2709 2502 var targets: [(Worktree.ID, Repository.ID)] = [] 2710 - for (worktreeID, archivedDate) in state.archivedWorktreeDates { 2711 - guard archivedDate <= cutoff else { continue } 2503 + for archived in state.sidebar.archivedWorktrees { 2504 + let worktreeID = archived.worktreeID 2505 + guard archived.archivedAt <= cutoff else { continue } 2712 2506 guard !state.deletingWorktreeIDs.contains(worktreeID), 2713 2507 !state.deleteScriptWorktreeIDs.contains(worktreeID), 2714 2508 !state.archivingWorktreeIDs.contains(worktreeID) ··· 2954 2748 state.archivingWorktreeIDs = filteredArchivingIDs 2955 2749 state.worktreeInfoByID = filteredWorktreeInfo 2956 2750 } 2957 - let didPrunePinned = prunePinnedWorktreeIDs(state: &state) 2958 - let didPruneCollapsedRepositoryIDs = pruneCollapsedRepositoryIDs(state: &state) 2959 - let didPruneRepositoryOrder = pruneRepositoryOrderIDs(roots: roots, state: &state) 2960 - let didPruneWorktreeOrder = pruneWorktreeOrderByRepository(roots: roots, state: &state) 2751 + // Reconcile unconditionally so the seed invariant ("every live 2752 + // non-main worktree has a bucket") holds after partial-failure 2753 + // loads too — gating this on `failures.isEmpty` would skip the 2754 + // seed pass whenever any root failed to resolve and leave 2755 + // `sidebar.sections` empty for the healthy repos, which breaks 2756 + // the view. Cross-repo archive loss on transient roster misses 2757 + // is already guarded by the orphan-preservation pass inside 2758 + // `reconcileSidebarState`, which copies `.archived` + `.pinned` 2759 + // forward for any repo that drops out of `availableRepoIDs`. 2760 + // 2761 + // Gate the `.pinned` / `.unpinned` liveness prune on the initial 2762 + // load: on the very first `.repositoriesLoaded` tick, 2763 + // `Repository.worktrees` hydration can race with the 2764 + // migrator-written IDs in `sidebar.json`, so a transient roster 2765 + // view may not yet contain every curated worktree. Skipping the 2766 + // destructive drop until the second load lets migrated curation 2767 + // survive that transient view. The seed pass and the 2768 + // orphan-preservation pass still run on the first load, so newly 2769 + // discovered worktrees still land in `.unpinned` and vanished 2770 + // repos still get tombstoned. 2771 + reconcileSidebarState( 2772 + roots: roots, 2773 + state: &state, 2774 + pruneLivenessAgainstRoster: state.isInitialLoadComplete 2775 + ) 2961 2776 let didPruneArchivedWorktreeIDs = 2962 2777 shouldPruneArchivedWorktreeIDs 2963 2778 ? pruneArchivedWorktreeIDs(availableWorktreeIDs: availableWorktreeIDs, state: &state) ··· 2968 2783 if state.shouldRestoreLastFocusedWorktree { 2969 2784 state.shouldRestoreLastFocusedWorktree = false 2970 2785 if state.selection == nil, 2971 - isSelectionValid(state.lastFocusedWorktreeID, state: state) 2786 + isSelectionValid(state.sidebar.focusedWorktreeID, state: state) 2972 2787 { 2973 - state.selection = state.lastFocusedWorktreeID.map(SidebarSelection.worktree) 2788 + state.selection = state.sidebar.focusedWorktreeID.map(SidebarSelection.worktree) 2974 2789 } 2975 2790 } 2976 2791 if state.selection == nil, state.shouldSelectFirstAfterReload { ··· 2978 2793 .map(SidebarSelection.worktree) 2979 2794 state.shouldSelectFirstAfterReload = false 2980 2795 } 2981 - return ApplyRepositoriesResult( 2982 - didPrunePinned: didPrunePinned, 2983 - didPruneCollapsedRepositoryIDs: didPruneCollapsedRepositoryIDs, 2984 - didPruneRepositoryOrder: didPruneRepositoryOrder, 2985 - didPruneWorktreeOrder: didPruneWorktreeOrder, 2986 - didPruneArchivedWorktreeIDs: didPruneArchivedWorktreeIDs 2987 - ) 2796 + return ApplyRepositoriesResult(didPruneArchivedWorktreeIDs: didPruneArchivedWorktreeIDs) 2988 2797 } 2989 2798 2990 2799 private func blockingScriptFailureAlert( ··· 3068 2877 3069 2878 var expandedRepositoryIDs: Set<Repository.ID> { 3070 2879 let repositoryIDs = Set(repositories.map(\.id)) 3071 - let collapsedSet = Set(collapsedRepositoryIDs).intersection(repositoryIDs) 2880 + let collapsedSet: Set<Repository.ID> = Set( 2881 + sidebar.sections.compactMap { $0.value.collapsed ? $0.key : nil } 2882 + ).intersection(repositoryIDs) 3072 2883 let pendingRepositoryIDs = Set(pendingWorktrees.map(\.repositoryID)) 3073 2884 return repositoryIDs.subtracting(collapsedSet).union(pendingRepositoryIDs) 3074 2885 } ··· 3104 2915 } 3105 2916 3106 2917 var archivedWorktreeIDs: [Worktree.ID] { 3107 - Array(archivedWorktreeDates.keys) 2918 + sidebar.archivedWorktrees.map(\.worktreeID) 3108 2919 } 3109 2920 3110 2921 var archivedWorktreeIDSet: Set<Worktree.ID> { 3111 - Set(archivedWorktreeDates.keys) 2922 + var set: Set<Worktree.ID> = [] 2923 + for section in sidebar.sections.values { 2924 + guard let archived = section.buckets[.archived] else { continue } 2925 + for worktreeID in archived.items.keys { 2926 + set.insert(worktreeID) 2927 + } 2928 + } 2929 + return set 3112 2930 } 3113 2931 3114 2932 func isWorktreeArchived(_ id: Worktree.ID) -> Bool { 3115 - archivedWorktreeDates[id] != nil 2933 + guard let repositoryID = repositoryID(containing: id) else { 2934 + return false 2935 + } 2936 + return sidebar.sections[repositoryID]?.buckets[.archived]?.items[id] != nil 3116 2937 } 3117 2938 3118 2939 func worktreeInfo(for worktreeID: Worktree.ID) -> WorktreeInfoEntry? { ··· 3241 3062 return makeWorktreeRow( 3242 3063 worktree, 3243 3064 repositoryID: repository.id, 3244 - isPinned: pinnedWorktreeIDs.contains(worktree.id), 3065 + isPinned: isWorktreePinned(worktree), 3245 3066 isMainWorktree: isMainWorktree(worktree) 3246 3067 ) 3247 3068 } ··· 3261 3082 ) 3262 3083 var ordered: [URL] = [] 3263 3084 var seen: Set<Repository.ID> = [] 3264 - for id in repositoryOrderIDs { 3085 + for id in sidebar.sections.keys { 3265 3086 if let rootURL = rootsByID[id], seen.insert(id).inserted { 3266 3087 ordered.append(rootURL) 3267 3088 } ··· 3302 3123 } 3303 3124 3304 3125 func orderedPinnedWorktreeIDs(in repository: Repository) -> [Worktree.ID] { 3305 - let archivedSet = archivedWorktreeIDSet 3306 - return pinnedWorktreeIDs.filter { id in 3307 - if archivedSet.contains(id) { 3308 - return false 3309 - } 3310 - if let worktree = repository.worktrees[id: id] { 3311 - return !isMainWorktree(worktree) 3312 - } 3313 - return false 3126 + let mainID = repository.worktrees.first(where: { isMainWorktree($0) })?.id 3127 + let availableIDs = Set(repository.worktrees.map(\.id)) 3128 + let pinnedKeys = sidebar.sections[repository.id]?.buckets[.pinned]?.items.keys ?? [] 3129 + return pinnedKeys.filter { id in 3130 + id != mainID && availableIDs.contains(id) 3314 3131 } 3315 3132 } 3316 3133 ··· 3318 3135 orderedPinnedWorktreeIDs(in: repository).compactMap { repository.worktrees[id: $0] } 3319 3136 } 3320 3137 3321 - func replacingPinnedWorktreeIDs( 3322 - in repository: Repository, 3323 - with reordered: [Worktree.ID] 3324 - ) -> [Worktree.ID] { 3325 - let repoPinnedIDs = Set(orderedPinnedWorktreeIDs(in: repository)) 3326 - var iterator = reordered.makeIterator() 3327 - return pinnedWorktreeIDs.map { id in 3328 - if repoPinnedIDs.contains(id) { 3329 - return iterator.next() ?? id 3330 - } 3331 - return id 3332 - } 3333 - } 3334 - 3335 3138 func orderedUnpinnedWorktreeIDs(in repository: Repository) -> [Worktree.ID] { 3336 3139 let mainID = repository.worktrees.first(where: { isMainWorktree($0) })?.id 3337 - let pinnedSet = Set(pinnedWorktreeIDs) 3338 - let archivedSet = archivedWorktreeIDSet 3140 + let section = sidebar.sections[repository.id] 3141 + let pinnedKeys = Set(section?.buckets[.pinned]?.items.keys ?? []) 3142 + let archivedKeys = Set(section?.buckets[.archived]?.items.keys ?? []) 3339 3143 let available = repository.worktrees.filter { worktree in 3340 3144 worktree.id != mainID 3341 - && !pinnedSet.contains(worktree.id) 3342 - && !archivedSet.contains(worktree.id) 3145 + && !pinnedKeys.contains(worktree.id) 3146 + && !archivedKeys.contains(worktree.id) 3343 3147 } 3344 - let orderedIDs = worktreeOrderByRepository[repository.id] ?? [] 3345 3148 let availableIDs = Set(available.map(\.id)) 3346 - let orderedIDSet = Set(orderedIDs) 3149 + let orderedKeys = section?.buckets[.unpinned]?.items.keys ?? [] 3150 + let orderedIDSet = Set(orderedKeys) 3347 3151 var seen: Set<Worktree.ID> = [] 3348 3152 var missing: [Worktree.ID] = [] 3349 3153 for worktree in available where !orderedIDSet.contains(worktree.id) { ··· 3352 3156 } 3353 3157 } 3354 3158 var ordered: [Worktree.ID] = [] 3355 - for id in orderedIDs { 3159 + for id in orderedKeys { 3356 3160 if availableIDs.contains(id), 3357 3161 seen.insert(id).inserted 3358 3162 { ··· 3379 3183 } 3380 3184 3381 3185 func isWorktreePinned(_ worktree: Worktree) -> Bool { 3382 - pinnedWorktreeIDs.contains(worktree.id) 3186 + guard let owningRepositoryID = repositoryID(containing: worktree.id) else { 3187 + return false 3188 + } 3189 + return sidebar.sections[owningRepositoryID]?.buckets[.pinned]?.items[worktree.id] != nil 3383 3190 } 3384 3191 3385 3192 var confirmWorktreeAlert: RepositoriesFeature.Alert? { ··· 3511 3318 3512 3319 private struct FailedWorktreeCleanup { 3513 3320 let didRemoveWorktree: Bool 3514 - let didUpdatePinned: Bool 3515 - let didUpdateOrder: Bool 3516 3321 let worktree: Worktree? 3517 3322 } 3518 3323 ··· 3578 3383 state: inout RepositoriesFeature.State 3579 3384 ) -> FailedWorktreeCleanup { 3580 3385 guard let name, !name.isEmpty else { 3581 - return FailedWorktreeCleanup( 3582 - didRemoveWorktree: false, 3583 - didUpdatePinned: false, 3584 - didUpdateOrder: false, 3585 - worktree: nil 3586 - ) 3386 + return FailedWorktreeCleanup(didRemoveWorktree: false, worktree: nil) 3587 3387 } 3588 3388 let repositoryRootURL = URL(fileURLWithPath: repositoryID).standardizedFileURL 3589 3389 let normalizedBaseDirectory = baseDirectory.standardizedFileURL ··· 3592 3392 .appending(path: name, directoryHint: .isDirectory) 3593 3393 .standardizedFileURL 3594 3394 guard isPathInsideBaseDirectory(worktreeURL, baseDirectory: normalizedBaseDirectory) else { 3595 - return FailedWorktreeCleanup( 3596 - didRemoveWorktree: false, 3597 - didUpdatePinned: false, 3598 - didUpdateOrder: false, 3599 - worktree: nil 3600 - ) 3395 + return FailedWorktreeCleanup(didRemoveWorktree: false, worktree: nil) 3601 3396 } 3602 3397 let worktreeID = worktreeURL.path(percentEncoded: false) 3603 3398 let worktree = ··· 3616 3411 ) 3617 3412 return FailedWorktreeCleanup( 3618 3413 didRemoveWorktree: cleanup.didRemoveWorktree, 3619 - didUpdatePinned: cleanup.didUpdatePinned, 3620 - didUpdateOrder: cleanup.didUpdateOrder, 3621 3414 worktree: worktree 3622 3415 ) 3623 3416 } ··· 3633 3426 3634 3427 private struct WorktreeCleanupStateResult { 3635 3428 let didRemoveWorktree: Bool 3636 - let didUpdatePinned: Bool 3637 - let didUpdateOrder: Bool 3638 3429 } 3639 3430 3640 3431 private func cleanupWorktreeState( ··· 3650 3441 state.deleteScriptWorktreeIDs.remove(worktreeID) 3651 3442 state.deletingWorktreeIDs.remove(worktreeID) 3652 3443 state.worktreeInfoByID.removeValue(forKey: worktreeID) 3653 - let didUpdatePinned = state.pinnedWorktreeIDs.contains(worktreeID) 3654 - if didUpdatePinned { 3655 - state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 3656 - } 3657 - var didUpdateOrder = false 3658 - if var order = state.worktreeOrderByRepository[repositoryID] { 3659 - let countBefore = order.count 3660 - order.removeAll { $0 == worktreeID } 3661 - if order.count != countBefore { 3662 - didUpdateOrder = true 3663 - if order.isEmpty { 3664 - state.worktreeOrderByRepository.removeValue(forKey: repositoryID) 3665 - } else { 3666 - state.worktreeOrderByRepository[repositoryID] = order 3667 - } 3668 - } 3444 + // Drop the worktree from every bucket in its section — a failed 3445 + // worktree creation is going away entirely so the bucket it 3446 + // currently lives in doesn't matter. 3447 + state.$sidebar.withLock { sidebar in 3448 + sidebar.removeAnywhere(worktree: worktreeID, in: repositoryID) 3669 3449 } 3670 - return WorktreeCleanupStateResult( 3671 - didRemoveWorktree: didRemoveWorktree, 3672 - didUpdatePinned: didUpdatePinned, 3673 - didUpdateOrder: didUpdateOrder 3674 - ) 3450 + return WorktreeCleanupStateResult(didRemoveWorktree: didRemoveWorktree) 3675 3451 } 3676 3452 3677 3453 private nonisolated func blockingScriptExitMessage(_ exitCode: Int) -> String { ··· 3941 3717 return nil 3942 3718 } 3943 3719 3944 - private func prunePinnedWorktreeIDs(state: inout RepositoriesFeature.State) -> Bool { 3945 - let availableIDs = Set(state.repositories.flatMap { $0.worktrees.map(\.id) }) 3946 - let mainIDs = Set( 3947 - state.repositories.compactMap { repository in 3948 - repository.worktrees.first(where: { state.isMainWorktree($0) })?.id 3949 - } 3950 - ) 3951 - let archivedSet = state.archivedWorktreeIDSet 3952 - let pruned = state.pinnedWorktreeIDs.filter { 3953 - availableIDs.contains($0) 3954 - && !mainIDs.contains($0) 3955 - && !archivedSet.contains($0) 3956 - } 3957 - if pruned != state.pinnedWorktreeIDs { 3958 - state.pinnedWorktreeIDs = pruned 3959 - return true 3960 - } 3961 - return false 3962 - } 3963 - 3964 - private func pruneCollapsedRepositoryIDs(state: inout RepositoriesFeature.State) -> Bool { 3965 - let repositoryIDs = Set(state.repositories.map(\.id)) 3966 - var didChange = false 3967 - state.$collapsedRepositoryIDs.withLock { collapsedRepositoryIDs in 3968 - let pruned = collapsedRepositoryIDs.filter { repositoryIDs.contains($0) } 3969 - didChange = pruned != collapsedRepositoryIDs 3970 - guard didChange else { return } 3971 - collapsedRepositoryIDs = pruned 3972 - } 3973 - return didChange 3974 - } 3975 - 3976 - private func pruneRepositoryOrderIDs( 3720 + /// Reconcile the nested `SidebarState` against the currently-known 3721 + /// repositories + worktrees in one atomic `$sidebar.withLock`. 3722 + /// Replaces the legacy four-way prune (pinned / collapsed / repo 3723 + /// order / worktree order) that each needed a separate save effect. 3724 + /// 3725 + /// Keeps section entries for `roots` that have not yet materialised 3726 + /// as loaded `Repository` instances — the loaded-roots-but-not- 3727 + /// resolved window happens every startup, and nuking collapse/order 3728 + /// state there would silently reset the user's curation. Inside a 3729 + /// resolved section, drops items whose worktree no longer exists 3730 + /// and isn't archived, and items that point at the repo's "main" 3731 + /// worktree (main rows don't live in the sidebar list). Archived 3732 + /// items (`archivedAt != nil`) stay put regardless of live-roster 3733 + /// membership — they ARE the archive record, not a duplicate of it. 3734 + /// 3735 + /// Also seeds `.unpinned` entries for every live non-main worktree 3736 + /// that isn't already curated in some bucket, so the mutation path 3737 + /// can assume "every live worktree has a bucketed entry" and the 3738 + /// pin/archive actions don't need fallback materialisation. This 3739 + /// seed is the load-bearing invariant the rest of the reducer relies 3740 + /// on — renaming away from "prune" makes that contract explicit. 3741 + /// 3742 + /// Finally, sections whose repository has disappeared from the live 3743 + /// roots but still carry user-curated `.archived` or `.pinned` 3744 + /// buckets are carried forward as stripped tombstones (archived + 3745 + /// pinned only, collapsed reset, `.unpinned` dropped) so a repo 3746 + /// temporarily missing from a partial reload doesn't destroy the 3747 + /// archive record or pin list. 3748 + /// 3749 + /// The rebuilt `sections` is compared to the current value before 3750 + /// the `withLock`; identical rebuilds short-circuit so branch-flutter 3751 + /// reloads don't re-encode + re-save `sidebar.json` on every tick. 3752 + /// 3753 + /// `pruneLivenessAgainstRoster` gates the destructive drop of 3754 + /// `.pinned` / `.unpinned` items whose worktree isn't in the live 3755 + /// roster. When `false`, curated items in those buckets are copied 3756 + /// forward verbatim — only the main-row filter and the seed pass 3757 + /// apply. The call site passes `state.isInitialLoadComplete` so the 3758 + /// first `.repositoriesLoaded` (which can race with 3759 + /// `Repository.worktrees` hydration and transiently miss 3760 + /// migrator-written IDs) can't silently drop curation. Subsequent 3761 + /// loads prune as before. 3762 + private func reconcileSidebarState( 3977 3763 roots: [URL], 3978 - state: inout RepositoriesFeature.State 3979 - ) -> Bool { 3980 - let rootIDs = roots.map { $0.standardizedFileURL.path(percentEncoded: false) } 3981 - let availableIDs = Set(rootIDs + state.repositories.map(\.id)) 3982 - let pruned = state.repositoryOrderIDs.filter { availableIDs.contains($0) } 3983 - if pruned != state.repositoryOrderIDs { 3984 - state.repositoryOrderIDs = pruned 3985 - return true 3764 + state: inout RepositoriesFeature.State, 3765 + pruneLivenessAgainstRoster: Bool 3766 + ) { 3767 + // Empty-everything reload → bail. A settings-file read failure or 3768 + // a pre-rehydration window can land here with zero roots + zero 3769 + // repos; overwriting `sidebar.json` from that state would 3770 + // obliterate the user's curation. 3771 + if roots.isEmpty, state.repositories.isEmpty { 3772 + return 3986 3773 } 3987 - return false 3988 - } 3989 3774 3990 - private func pruneWorktreeOrderByRepository( 3991 - roots: [URL], 3992 - state: inout RepositoriesFeature.State 3993 - ) -> Bool { 3994 - let rootIDs = Set(roots.map { $0.standardizedFileURL.path(percentEncoded: false) }) 3775 + let rootIDs: Set<Repository.ID> = Set(roots.map { $0.standardizedFileURL.path(percentEncoded: false) }) 3776 + let localIDs = Set(state.repositories.map(\.id)) 3777 + let availableRepoIDs = localIDs.union(rootIDs) 3995 3778 let repositoriesByID = Dictionary(uniqueKeysWithValues: state.repositories.map { ($0.id, $0) }) 3996 - let pinnedSet = Set(state.pinnedWorktreeIDs) 3997 - let archivedSet = state.archivedWorktreeIDSet 3998 - var pruned: [Repository.ID: [Worktree.ID]] = [:] 3999 - for (repoID, order) in state.worktreeOrderByRepository { 3779 + 3780 + var rebuilt: OrderedDictionary<Repository.ID, SidebarState.Section> = [:] 3781 + for (repoID, section) in state.sidebar.sections where availableRepoIDs.contains(repoID) { 4000 3782 guard let repository = repositoriesByID[repoID] else { 4001 - if rootIDs.contains(repoID), !order.isEmpty { 4002 - pruned[repoID] = order 4003 - } 3783 + // Local roots still loading. Preserve the section verbatim 3784 + // — we'll re-prune its items once the roster is known. 3785 + rebuilt[repoID] = section 4004 3786 continue 4005 3787 } 4006 3788 let mainID = repository.worktrees.first(where: { state.isMainWorktree($0) })?.id 4007 - let availableIDs = Set(repository.worktrees.map(\.id)) 4008 - var seen: Set<Worktree.ID> = [] 4009 - var filtered: [Worktree.ID] = [] 4010 - for id in order { 4011 - if availableIDs.contains(id), 4012 - id != mainID, 4013 - !pinnedSet.contains(id), 4014 - !archivedSet.contains(id), 4015 - seen.insert(id).inserted 4016 - { 4017 - filtered.append(id) 3789 + let worktreeIDs = Set(repository.worktrees.map(\.id)) 3790 + var copy = section 3791 + // Walk every bucket. `.archived` is the archive record — 3792 + // preserve its items regardless of live-roster membership. 3793 + // `.pinned` and `.unpinned` only hold curated pointers into 3794 + // the live roster, so normally drop entries whose worktree 3795 + // no longer exists or that point at the main row. When 3796 + // `pruneLivenessAgainstRoster` is `false` (first load after 3797 + // migration), keep every curated item that isn't the main 3798 + // row so migrated IDs survive a transient roster view; the 3799 + // next `.repositoriesLoaded` will prune for real. 3800 + var seenInCuratedBuckets: Set<Worktree.ID> = [] 3801 + for (bucketID, bucket) in copy.buckets { 3802 + if bucketID == .archived { 3803 + continue 4018 3804 } 3805 + var prunedItems: OrderedDictionary<Worktree.ID, SidebarState.Item> = [:] 3806 + for (worktreeID, item) in bucket.items { 3807 + if worktreeID == mainID { 3808 + continue 3809 + } 3810 + if pruneLivenessAgainstRoster, !worktreeIDs.contains(worktreeID) { 3811 + continue 3812 + } 3813 + prunedItems[worktreeID] = item 3814 + seenInCuratedBuckets.insert(worktreeID) 3815 + } 3816 + var prunedBucket = bucket 3817 + prunedBucket.items = prunedItems 3818 + copy.buckets[bucketID] = prunedBucket 4019 3819 } 4020 - if !filtered.isEmpty { 4021 - pruned[repoID] = filtered 3820 + // Capture worktree IDs already living in `.archived` so the 3821 + // seed pass doesn't resurrect them into `.unpinned`. 3822 + var archivedIDs: Set<Worktree.ID> = [] 3823 + if let archivedBucket = copy.buckets[.archived] { 3824 + archivedIDs = Set(archivedBucket.items.keys) 4022 3825 } 3826 + // Seed every live non-main worktree that isn't already curated 3827 + // in some bucket into `.unpinned` at the tail. This makes the 3828 + // sidebar state total, so mutation actions can assume every 3829 + // live worktree has a bucket and skip fallback materialisation. 3830 + for worktree in repository.worktrees { 3831 + if worktree.id == mainID { 3832 + continue 3833 + } 3834 + if seenInCuratedBuckets.contains(worktree.id) || archivedIDs.contains(worktree.id) { 3835 + continue 3836 + } 3837 + var unpinned = copy.buckets[.unpinned] ?? .init() 3838 + unpinned.items[worktree.id] = .init() 3839 + copy.buckets[.unpinned] = unpinned 3840 + } 3841 + rebuilt[repoID] = copy 4023 3842 } 4024 - if pruned != state.worktreeOrderByRepository { 4025 - state.worktreeOrderByRepository = pruned 4026 - return true 3843 + 3844 + preserveOrphanSections( 3845 + from: state.sidebar.sections, 3846 + availableRepoIDs: availableRepoIDs, 3847 + into: &rebuilt 3848 + ) 3849 + 3850 + // Equality-gate the write. Branch-change and filesystem-flutter 3851 + // reloads fire `.repositoriesLoaded` every few seconds even when 3852 + // the roster is unchanged; entering `$sidebar.withLock` with an 3853 + // identical rebuild would still trigger the SharedKey save path 3854 + // and re-encode + re-atomic-write `sidebar.json` needlessly. 3855 + guard rebuilt != state.sidebar.sections else { 3856 + return 4027 3857 } 4028 - return false 3858 + state.$sidebar.withLock { sidebar in 3859 + sidebar.sections = rebuilt 3860 + } 3861 + } 3862 + 3863 + /// Preserve user-curated `.archived` and `.pinned` buckets for 3864 + /// repositories no longer present in `availableRepoIDs`. A repo can 3865 + /// vanish from the live roster for legitimate reasons (removed from 3866 + /// Settings → Repositories) or transient ones (a partial reload 3867 + /// where resolution failed). In either case the archive record and 3868 + /// pin list are user-curated data we must not drop silently. This 3869 + /// emits a stripped tombstone section: only non-empty `.archived` 3870 + /// and `.pinned` are carried verbatim, `.unpinned` is dropped (it's 3871 + /// regenerated by the seed pass on the next full load), and 3872 + /// `collapsed` resets to its default because there's no rendered 3873 + /// section to collapse. Tombstones are appended after the active 3874 + /// repos so the natural ordering stays "live repos first, 3875 + /// orphan-but-curated at the tail". 3876 + private func preserveOrphanSections( 3877 + from oldSections: OrderedDictionary<Repository.ID, SidebarState.Section>, 3878 + availableRepoIDs: Set<Repository.ID>, 3879 + into rebuilt: inout OrderedDictionary<Repository.ID, SidebarState.Section> 3880 + ) { 3881 + for (repoID, section) in oldSections where !availableRepoIDs.contains(repoID) { 3882 + var preservedBuckets: OrderedDictionary<SidebarState.BucketID, SidebarState.Bucket> = [:] 3883 + if let archived = section.buckets[.archived], !archived.items.isEmpty { 3884 + preservedBuckets[.archived] = archived 3885 + } 3886 + if let pinned = section.buckets[.pinned], !pinned.items.isEmpty { 3887 + preservedBuckets[.pinned] = pinned 3888 + } 3889 + guard !preservedBuckets.isEmpty else { continue } 3890 + rebuilt[repoID] = .init(collapsed: false, buckets: preservedBuckets) 3891 + } 4029 3892 } 4030 3893 4031 3894 private func pruneArchivedWorktreeIDs( 4032 3895 availableWorktreeIDs: Set<Worktree.ID>, 4033 3896 state: inout RepositoriesFeature.State 4034 3897 ) -> Bool { 4035 - let before = state.archivedWorktreeDates.count 4036 - state.archivedWorktreeDates = state.archivedWorktreeDates.filter { availableWorktreeIDs.contains($0.key) } 4037 - return state.archivedWorktreeDates.count != before 3898 + var didChange = false 3899 + state.$sidebar.withLock { sidebar in 3900 + for (repoID, section) in sidebar.sections { 3901 + guard let archived = section.buckets[.archived] else { continue } 3902 + for worktreeID in archived.items.keys 3903 + where !availableWorktreeIDs.contains(worktreeID) { 3904 + sidebar.sections[repoID]?.buckets[.archived]?.items.removeValue(forKey: worktreeID) 3905 + didChange = true 3906 + } 3907 + } 3908 + } 3909 + return didChange 4038 3910 } 4039 3911 4040 3912 private func firstAvailableWorktreeID(
+19 -16
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 303 303 ) 304 304 } 305 305 306 - let archiveLabel = isBulkSelection ? "Archive Worktrees…" : "Archive Worktree…" 307 - Button(archiveLabel, systemImage: "archivebox") { 308 - if archiveTargets.count == 1, let target = archiveTargets.first { 309 - store.send(.requestArchiveWorktree(target.worktreeID, target.repositoryID)) 310 - } else { 311 - store.send(.requestArchiveWorktrees(archiveTargets)) 306 + if !archiveTargets.isEmpty || !deleteTargets.isEmpty { 307 + let archiveLabel = isBulkSelection ? "Archive Worktrees…" : "Archive Worktree…" 308 + Button(archiveLabel, systemImage: "archivebox") { 309 + if archiveTargets.count == 1, let target = archiveTargets.first { 310 + store.send(.requestArchiveWorktree(target.worktreeID, target.repositoryID)) 311 + } else { 312 + store.send(.requestArchiveWorktrees(archiveTargets)) 313 + } 312 314 } 313 - } 314 - .appKeyboardShortcut(archiveShortcut) 315 - .disabled(archiveTargets.isEmpty) 315 + .appKeyboardShortcut(archiveShortcut) 316 + .disabled(archiveTargets.isEmpty) 316 317 317 - let deleteLabel = isBulkSelection ? "Delete Worktrees…" : "Delete Worktree…" 318 - Button(deleteLabel, systemImage: "trash", role: .destructive) { 319 - if deleteTargets.count == 1, let target = deleteTargets.first { 320 - store.send(.requestDeleteWorktree(target.worktreeID, target.repositoryID)) 321 - } else { 322 - store.send(.requestDeleteWorktrees(deleteTargets)) 318 + let deleteLabel = isBulkSelection ? "Delete Worktrees…" : "Delete Worktree…" 319 + Button(deleteLabel, systemImage: "trash", role: .destructive) { 320 + if deleteTargets.count == 1, let target = deleteTargets.first { 321 + store.send(.requestDeleteWorktree(target.worktreeID, target.repositoryID)) 322 + } else { 323 + store.send(.requestDeleteWorktrees(deleteTargets)) 324 + } 323 325 } 326 + .appKeyboardShortcut(deleteShortcut) 327 + .disabled(deleteTargets.isEmpty) 324 328 } 325 - .appKeyboardShortcut(deleteShortcut) 326 329 } 327 330 328 331 @ViewBuilder
+15 -6
supacodeTests/AppFeatureArchivedSelectionTests.swift
··· 2 2 import DependenciesTestSupport 3 3 import Foundation 4 4 import IdentifiedCollections 5 + import OrderedCollections 6 + import Sharing 5 7 import Testing 6 8 7 9 @testable import SupacodeSettingsFeature ··· 27 29 ) 28 30 var repositoriesState = RepositoriesFeature.State(repositories: [repository]) 29 31 repositoriesState.selection = .worktree(worktree.id) 30 - let saved = LockIsolated<[Worktree.ID?]>([]) 32 + let priorFocus = repositoriesState.sidebar.focusedWorktreeID 31 33 let store = TestStore( 32 34 initialState: AppFeature.State( 33 35 repositories: repositoriesState, ··· 36 38 ) { 37 39 AppFeature() 38 40 } withDependencies: { 39 - $0.repositoryPersistence.saveLastFocusedWorktreeID = { id in 40 - saved.withValue { $0.append(id) } 41 - } 42 41 $0.terminalClient.send = { _ in } 43 42 $0.worktreeInfoWatcher.send = { _ in } 44 43 } ··· 48 47 } 49 48 await store.receive(\.repositories.delegate.selectedWorktreeChanged) 50 49 await store.finish() 51 - #expect(saved.value.isEmpty) 50 + // Selecting the archived list must NOT overwrite the last 51 + // focused live worktree — the sidebar focus should be 52 + // untouched so returning from archives restores the prior row. 53 + #expect(store.state.repositories.sidebar.focusedWorktreeID == priorFocus) 52 54 } 53 55 54 56 @Test(.dependencies) func repositoriesChangedPrunesArchivedWorktreesFromTerminalAndRunScriptStatus() async { ··· 75 77 ) 76 78 var repositoriesState = RepositoriesFeature.State(repositories: [repository]) 77 79 repositoriesState.selection = .worktree(activeWorktree.id) 78 - repositoriesState.archivedWorktreeDates[archivedWorktree.id] = Date(timeIntervalSince1970: 1_000_000) 80 + repositoriesState.$sidebar.withLock { sidebar in 81 + sidebar.insert( 82 + worktree: archivedWorktree.id, 83 + in: repository.id, 84 + bucket: .archived, 85 + item: .init(archivedAt: Date(timeIntervalSince1970: 1_000_000)) 86 + ) 87 + } 79 88 var appState = AppFeature.State( 80 89 repositories: repositoriesState, 81 90 settings: SettingsFeature.State()
+8 -1
supacodeTests/AppFeatureDeeplinkTests.swift
··· 2 2 import Darwin 3 3 import DependenciesTestSupport 4 4 import Foundation 5 + import OrderedCollections 5 6 import Sharing 6 7 import Testing 7 8 ··· 42 43 @Test(.dependencies) func unpinWorktreeDeeplink() async { 43 44 let worktree = makeWorktree() 44 45 var repositories = makeRepositoriesState(worktree: worktree) 45 - repositories.pinnedWorktreeIDs = [worktree.id] 46 + let repositoryID = repositories.repositories.first?.id 47 + repositories.$sidebar.withLock { sidebar in 48 + guard let repositoryID else { return } 49 + sidebar.sections[repositoryID, default: .init()] 50 + .buckets[.pinned, default: .init()] 51 + .items[worktree.id] = .init() 52 + } 46 53 let store = TestStore( 47 54 initialState: AppFeature.State( 48 55 repositories: repositories,
+15 -4
supacodeTests/AppFeatureDefaultEditorTests.swift
··· 34 34 } 35 35 } 36 36 37 - await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) 37 + await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) { 38 + $0.repositories.$sidebar.withLock { sidebar in 39 + sidebar.focusedWorktreeID = worktree.id 40 + } 41 + } 38 42 await store.receive(\.worktreeSettingsLoaded) 39 43 #expect(store.state.openActionSelection == .finder) 40 44 #expect(store.state.scripts.isEmpty) ··· 93 97 $0.repositoryLocalSettingsStorage = localStorage.storage 94 98 } 95 99 96 - await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) 100 + await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) { 101 + $0.repositories.$sidebar.withLock { sidebar in 102 + sidebar.focusedWorktreeID = worktree.id 103 + } 104 + } 97 105 await store.receive(\.worktreeSettingsLoaded) { 98 106 $0.openActionSelection = .terminal 99 107 $0.scripts = localRepositorySettings.scripts ··· 118 126 ) { 119 127 AppFeature() 120 128 } withDependencies: { 121 - $0.repositoryPersistence.saveLastFocusedWorktreeID = { _ in } 122 129 $0.terminalClient.send = { _ in } 123 130 $0.worktreeInfoWatcher.send = { command in 124 131 watcherCommands.withValue { $0.append(command) } ··· 127 134 $0.settingsFileURL = settingsFileURL 128 135 } 129 136 130 - await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) 137 + await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) { 138 + $0.repositories.$sidebar.withLock { sidebar in 139 + sidebar.focusedWorktreeID = worktree.id 140 + } 141 + } 131 142 await store.receive(\.worktreeSettingsLoaded) { 132 143 $0.openActionSelection = expectedOpenActionSelection 133 144 }
-73
supacodeTests/ArchivedWorktreeDatesClientTests.swift
··· 1 - import Dependencies 2 - import DependenciesTestSupport 3 - import Foundation 4 - import Sharing 5 - import Testing 6 - 7 - @testable import SupacodeSettingsShared 8 - @testable import supacode 9 - 10 - struct ArchivedWorktreeDatesClientTests { 11 - @Test(.dependencies) func loadNormalizesStoredKeys() async { 12 - let suiteName = "ArchivedWorktreeDatesClientTests.load.\(UUID().uuidString)" 13 - let store = UserDefaults(suiteName: suiteName)! 14 - store.removePersistentDomain(forName: suiteName) 15 - 16 - await withDependencies { 17 - $0.defaultAppStorage = store 18 - } operation: { 19 - let date = Date(timeIntervalSince1970: 1_000_000) 20 - @Shared(.appStorage(archivedWorktreeDatesStorageKey)) var archivedDates: [String: Date] = [:] 21 - $archivedDates.withLock { $0 = ["/tmp/repo/../repo/feature": date] } 22 - 23 - let result = await ArchivedWorktreeDatesClient.liveValue.load() 24 - 25 - #expect(result == ["/tmp/repo/feature": date]) 26 - #expect(archivedDates == ["/tmp/repo/feature": date]) 27 - } 28 - } 29 - 30 - @Test(.dependencies) func loadMigratesLegacyIDs() async { 31 - let suiteName = "ArchivedWorktreeDatesClientTests.migrate.\(UUID().uuidString)" 32 - let store = UserDefaults(suiteName: suiteName)! 33 - store.removePersistentDomain(forName: suiteName) 34 - 35 - await withDependencies { 36 - $0.defaultAppStorage = store 37 - } operation: { 38 - @Shared(.appStorage("archivedWorktreeIDs")) var legacyIDs: [String] = [] 39 - @Shared(.appStorage(archivedWorktreeDatesStorageKey)) var archivedDates: [String: Date] = [:] 40 - $legacyIDs.withLock { $0 = ["/tmp/repo/feature", "/tmp/repo/bugfix"] } 41 - $archivedDates.withLock { $0 = [:] } 42 - 43 - let result = await ArchivedWorktreeDatesClient.liveValue.load() 44 - 45 - #expect(result.count == 2) 46 - #expect(result["/tmp/repo/feature"] != nil) 47 - #expect(result["/tmp/repo/bugfix"] != nil) 48 - #expect(legacyIDs.isEmpty) 49 - #expect(archivedDates == result) 50 - } 51 - } 52 - 53 - @Test(.dependencies) func saveNormalizesKeys() async { 54 - let suiteName = "ArchivedWorktreeDatesClientTests.save.\(UUID().uuidString)" 55 - let store = UserDefaults(suiteName: suiteName)! 56 - store.removePersistentDomain(forName: suiteName) 57 - 58 - await withDependencies { 59 - $0.defaultAppStorage = store 60 - } operation: { 61 - let older = Date(timeIntervalSince1970: 1_000_000) 62 - let newer = Date(timeIntervalSince1970: 2_000_000) 63 - 64 - await ArchivedWorktreeDatesClient.liveValue.save([ 65 - "/tmp/repo/feature": older, 66 - "/tmp/repo/../repo/feature": newer, 67 - ]) 68 - 69 - @Shared(.appStorage(archivedWorktreeDatesStorageKey)) var archivedDates: [String: Date] = [:] 70 - #expect(archivedDates == ["/tmp/repo/feature": newer]) 71 - } 72 - } 73 - }
+10 -2
supacodeTests/CommandPaletteFeatureTests.swift
··· 2 2 import CustomDump 3 3 import Foundation 4 4 import IdentifiedCollections 5 + import OrderedCollections 6 + import Sharing 5 7 import SupacodeSettingsShared 6 8 import Testing 7 9 ··· 286 288 unpinned, 287 289 ]) 288 290 var state = RepositoriesFeature.State(repositories: [repository]) 289 - state.pinnedWorktreeIDs = [pinned.id] 290 - state.worktreeOrderByRepository = [repository.id: [unpinned.id]] 291 + state.$sidebar.withLock { sidebar in 292 + sidebar.sections[repository.id] = .init( 293 + buckets: [ 294 + .pinned: .init(items: [pinned.id: .init()]), 295 + .unpinned: .init(items: [unpinned.id: .init()]), 296 + ] 297 + ) 298 + } 291 299 292 300 let items = CommandPaletteFeature.commandPaletteItems(from: state) 293 301 let selectIDs = items.compactMap { item in
-65
supacodeTests/RepositoriesFeaturePersistenceTests.swift
··· 1 - import ComposableArchitecture 2 - import DependenciesTestSupport 3 - import Foundation 4 - import Testing 5 - 6 - @testable import supacode 7 - 8 - @MainActor 9 - struct RepositoriesFeaturePersistenceTests { 10 - @Test(.dependencies) func taskLoadsPinnedWorktreesBeforeRepositories() async { 11 - let pinned = ["/tmp/repo/wt-1"] 12 - let repositoryOrder = ["/tmp/repo"] 13 - let worktreeOrder = ["/tmp/repo": ["/tmp/repo/wt-1"]] 14 - let calls = LockIsolated<[String]>([]) 15 - let store = TestStore(initialState: RepositoriesFeature.State()) { 16 - RepositoriesFeature() 17 - } withDependencies: { 18 - $0.repositoryPersistence = RepositoryPersistenceClient( 19 - loadRoots: { 20 - calls.withValue { $0.append("loadRoots") } 21 - return [] 22 - }, 23 - saveRoots: { _ in }, 24 - loadPinnedWorktreeIDs: { 25 - calls.withValue { $0.append("loadPinnedWorktreeIDs") } 26 - return pinned 27 - }, 28 - savePinnedWorktreeIDs: { _ in }, 29 - loadArchivedWorktreeDates: { 30 - calls.withValue { $0.append("loadArchivedWorktreeDates") } 31 - return [:] 32 - }, 33 - saveArchivedWorktreeDates: { _ in }, 34 - loadRepositoryOrderIDs: { 35 - calls.withValue { $0.append("loadRepositoryOrderIDs") } 36 - return repositoryOrder 37 - }, 38 - saveRepositoryOrderIDs: { _ in }, 39 - loadWorktreeOrderByRepository: { 40 - calls.withValue { $0.append("loadWorktreeOrderByRepository") } 41 - return worktreeOrder 42 - }, 43 - saveWorktreeOrderByRepository: { _ in }, 44 - loadLastFocusedWorktreeID: { 45 - calls.withValue { $0.append("loadLastFocusedWorktreeID") } 46 - return nil 47 - }, 48 - saveLastFocusedWorktreeID: { _ in } 49 - ) 50 - } 51 - 52 - store.exhaustivity = .off 53 - await store.send(.task) 54 - await store.finish() 55 - #expect( 56 - calls.value == [ 57 - "loadPinnedWorktreeIDs", 58 - "loadArchivedWorktreeDates", 59 - "loadLastFocusedWorktreeID", 60 - "loadRepositoryOrderIDs", 61 - "loadWorktreeOrderByRepository", 62 - "loadRoots", 63 - ]) 64 - } 65 - }
+378 -84
supacodeTests/RepositoriesFeatureTests.swift
··· 4 4 import DependenciesTestSupport 5 5 import Foundation 6 6 import IdentifiedCollections 7 + import OrderedCollections 7 8 import Sharing 8 9 import Testing 9 10 ··· 66 67 } 67 68 } 68 69 70 + @Test func firstRepositoriesLoadedPreservesMigratedPinnedEntryMissingFromRoster() async { 71 + // T5 — first-load reconcile must not clobber migrated data. 72 + // The migrator writes pinned worktree IDs into `sidebar.json` 73 + // before the first git-roster hydration. If the first 74 + // `.repositoriesLoaded` tick sees a partial roster (e.g. the 75 + // `feature` worktree is still loading), the liveness prune 76 + // would silently drop the migrated pin and the user would 77 + // lose curation on launch. The reducer guards this by gating 78 + // the destructive prune on `state.isInitialLoadComplete`: 79 + // the seed + orphan-preservation passes still run, but the 80 + // curated `.pinned` items are copied forward verbatim. On 81 + // the SECOND tick (`isInitialLoadComplete == true`) the 82 + // prune resumes normally and a still-missing worktree is 83 + // finally dropped. 84 + let repoRoot = "/tmp/repo" 85 + let mainWorktree = Worktree( 86 + id: repoRoot, 87 + name: "main", 88 + detail: "detail", 89 + workingDirectory: URL(fileURLWithPath: repoRoot), 90 + repositoryRootURL: URL(fileURLWithPath: repoRoot), 91 + ) 92 + let featureWorktree = makeWorktree( 93 + id: "/tmp/repo/feature", 94 + name: "feature", 95 + repoRoot: repoRoot, 96 + ) 97 + // Initial repository list contains only the main worktree — 98 + // simulating the transient roster race on first boot where 99 + // the `feature` worktree hasn't hydrated yet. 100 + let mainOnlyRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 101 + 102 + var initialState = RepositoriesFeature.State() 103 + initialState.repositories = [mainOnlyRepository] 104 + initialState.repositoryRoots = [mainOnlyRepository.rootURL] 105 + initialState.isInitialLoadComplete = false 106 + initialState.$sidebar.withLock { sidebar in 107 + sidebar.sections[repoRoot] = .init( 108 + buckets: [.pinned: .init(items: [featureWorktree.id: .init()])] 109 + ) 110 + } 111 + 112 + let store = TestStore(initialState: initialState) { 113 + RepositoriesFeature() 114 + } 115 + 116 + // First tick: migrated pin MUST survive the transient roster. 117 + await store.send( 118 + .repositoriesLoaded( 119 + [mainOnlyRepository], 120 + failures: [], 121 + roots: [mainOnlyRepository.rootURL], 122 + animated: false, 123 + ) 124 + ) { 125 + $0.isInitialLoadComplete = true 126 + } 127 + #expect( 128 + store.state.sidebar.sections[repoRoot]?.buckets[.pinned]?.items[featureWorktree.id] != nil 129 + ) 130 + 131 + // Second tick with `isInitialLoadComplete == true`: the 132 + // stale pinned entry is now eligible for the destructive 133 + // drop because the reducer trusts the roster from load #2 134 + // onward. The drop happens inside the `$sidebar.withLock` 135 + // closure so the shared state is mutated in-place. 136 + await store.send( 137 + .repositoriesLoaded( 138 + [mainOnlyRepository], 139 + failures: [], 140 + roots: [mainOnlyRepository.rootURL], 141 + animated: false, 142 + ) 143 + ) { 144 + $0.$sidebar.withLock { sidebar in 145 + sidebar.sections[repoRoot] = .init(buckets: [.pinned: .init(items: [:])]) 146 + } 147 + } 148 + #expect( 149 + store.state.sidebar.sections[repoRoot]?.buckets[.pinned]?.items[featureWorktree.id] == nil 150 + ) 151 + } 152 + 69 153 @Test func selectWorktreeSendsDelegate() async { 70 154 let worktree = makeWorktree(id: "/tmp/wt", name: "fox") 71 155 let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) ··· 203 287 } 204 288 205 289 await store.send(.repositoryExpansionChanged(repository.id, isExpanded: false)) { 206 - $0.$collapsedRepositoryIDs.withLock { $0 = [repository.id] } 290 + $0.$sidebar.withLock { $0.sections[repository.id, default: .init()].collapsed = true } 207 291 } 208 292 209 293 await store.send(.repositoryExpansionChanged(repository.id, isExpanded: true)) { 210 - $0.$collapsedRepositoryIDs.withLock { $0 = [] } 294 + $0.$sidebar.withLock { $0.sections[repository.id, default: .init()].collapsed = false } 211 295 } 212 296 } 213 297 ··· 219 303 } 220 304 221 305 await store.send(.repositoryExpansionChanged(repository.id, isExpanded: false)) { 222 - $0.$collapsedRepositoryIDs.withLock { $0 = [repository.id] } 306 + $0.$sidebar.withLock { $0.sections[repository.id, default: .init()].collapsed = true } 223 307 } 224 308 225 309 // Collapsing again should be a no-op. ··· 276 360 worktrees: [makeWorktree(id: "\(repoBID)/wt1", name: "wt1", repoRoot: repoBID)] 277 361 ) 278 362 let initialState = makeState(repositories: [repoA, repoB]) 279 - initialState.$collapsedRepositoryIDs.withLock { $0 = [repoA.id, repoB.id, "/tmp/missing"] } 363 + initialState.$sidebar.withLock { sidebar in 364 + for id in [repoA.id, repoB.id, "/tmp/missing"] { 365 + sidebar.sections[id, default: .init()].collapsed = true 366 + } 367 + } 280 368 let store = TestStore(initialState: initialState) { 281 369 RepositoriesFeature() 282 370 } ··· 291 379 ) { 292 380 $0.repositories = [repoA] 293 381 $0.repositoryRoots = [repoA.rootURL] 294 - $0.$collapsedRepositoryIDs.withLock { $0 = [repoA.id] } 382 + $0.$sidebar.withLock { sidebar in 383 + var rebuilt: OrderedDictionary<Repository.ID, SidebarState.Section> = [:] 384 + rebuilt[repoA.id] = sidebar.sections[repoA.id] ?? .init() 385 + sidebar.sections = rebuilt 386 + } 295 387 $0.isInitialLoadComplete = true 296 388 } 297 389 await store.receive(\.delegate.repositoriesChanged) ··· 344 436 RepositoriesFeature() 345 437 } 346 438 347 - // Collapse B first, then A. 439 + // Collapse B first, then A. With the bucketed sidebar there 440 + // is no "sorted order" of collapsed IDs — collapse state lives 441 + // per-section, so the assertion is just that the .collapsed 442 + // bit flips on the targeted section. 348 443 await store.send(.repositoryExpansionChanged(repoB.id, isExpanded: false)) { 349 - $0.$collapsedRepositoryIDs.withLock { $0 = [repoB.id] } 444 + $0.$sidebar.withLock { $0.sections[repoB.id, default: .init()].collapsed = true } 350 445 } 351 446 await store.send(.repositoryExpansionChanged(repoA.id, isExpanded: false)) { 352 - $0.$collapsedRepositoryIDs.withLock { $0 = [repoA.id, repoB.id] } 447 + $0.$sidebar.withLock { $0.sections[repoA.id, default: .init()].collapsed = true } 353 448 } 354 449 } 355 450 ··· 464 559 var initialState = makeState(repositories: [repository]) 465 560 initialState.selection = .worktree(worktree.id) 466 561 initialState.sidebarSelectedWorktreeIDs = [worktree.id] 467 - initialState.$collapsedRepositoryIDs.withLock { $0 = [repository.id] } 562 + initialState.$sidebar.withLock { $0.sections[repository.id, default: .init()].collapsed = true } 468 563 let store = TestStore(initialState: initialState) { 469 564 RepositoriesFeature() 470 565 } 471 566 472 567 await store.send(.revealSelectedWorktreeInSidebar) { 473 - $0.$collapsedRepositoryIDs.withLock { $0 = [] } 568 + $0.$sidebar.withLock { $0.sections[repository.id, default: .init()].collapsed = false } 474 569 $0.nextPendingSidebarRevealID = 1 475 570 $0.pendingSidebarReveal = .init(id: 1, worktreeID: worktree.id) 476 571 } ··· 495 590 var initialState = makeState(repositories: [repoA, repoB]) 496 591 initialState.selection = .worktree(worktree1.id) 497 592 initialState.sidebarSelectedWorktreeIDs = [worktree1.id] 498 - initialState.$collapsedRepositoryIDs.withLock { $0 = [repoA.id, repoB.id] } 593 + initialState.$sidebar.withLock { sidebar in 594 + sidebar.sections[repoA.id, default: .init()].collapsed = true 595 + sidebar.sections[repoB.id, default: .init()].collapsed = true 596 + } 499 597 let store = TestStore(initialState: initialState) { 500 598 RepositoriesFeature() 501 599 } 502 600 503 601 await store.send(.revealSelectedWorktreeInSidebar) { 504 - $0.$collapsedRepositoryIDs.withLock { $0 = [repoB.id] } 602 + $0.$sidebar.withLock { $0.sections[repoA.id, default: .init()].collapsed = false } 505 603 $0.nextPendingSidebarRevealID = 1 506 604 $0.pendingSidebarReveal = .init(id: 1, worktreeID: worktree1.id) 507 605 } ··· 1732 1830 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1733 1831 var state = makeState(repositories: [repository]) 1734 1832 state.selection = .worktree(featureWorktree.id) 1735 - state.pinnedWorktreeIDs = [featureWorktree.id] 1736 - state.worktreeOrderByRepository[repoRoot] = [featureWorktree.id] 1833 + state.$sidebar.withLock { sidebar in 1834 + sidebar.sections[repository.id] = .init( 1835 + buckets: [.pinned: .init(items: [featureWorktree.id: .init()])] 1836 + ) 1837 + } 1737 1838 state.worktreeInfoByID = [ 1738 1839 featureWorktree.id: WorktreeInfoEntry( 1739 1840 addedLines: nil, ··· 1746 1847 RepositoriesFeature() 1747 1848 } 1748 1849 store.dependencies.date = .constant(fixedDate) 1850 + // Exhaustive receive closures on the archive chain are too 1851 + // noisy to assert line-by-line — TCA processes synchronous 1852 + // `.send` follow-ups inside the original `send`, so 1853 + // `archivingWorktreeIDs` + selection + sidebar transitions 1854 + // land in one tick and the diff drowns out the actual 1855 + // coverage we care about. Relax exhaustivity and pin the 1856 + // meaningful end state via `#expect` below. 1857 + store.exhaustivity = .off 1749 1858 1750 1859 await store.send(.requestArchiveWorktree(featureWorktree.id, repository.id)) 1751 - await store.receive(\.archiveWorktreeConfirmed) 1752 - await store.receive(\.archiveWorktreeApply) { 1753 - $0.archivedWorktreeDates[featureWorktree.id] = fixedDate 1754 - $0.pinnedWorktreeIDs = [] 1755 - $0.worktreeOrderByRepository = [:] 1756 - $0.selection = .worktree(mainWorktree.id) 1757 - } 1758 - await store.receive(\.delegate.repositoriesChanged) 1759 - await store.receive(\.delegate.selectedWorktreeChanged) 1860 + await store.receive(\.archiveWorktreeApply) 1861 + #expect( 1862 + store.state.sidebar.sections[repository.id]? 1863 + .buckets[.archived]?.items[featureWorktree.id]?.archivedAt == fixedDate 1864 + ) 1865 + #expect(store.state.sidebar.sections[repository.id]?.buckets[.pinned]?.items[featureWorktree.id] == nil) 1866 + #expect(store.state.selection == .worktree(mainWorktree.id)) 1760 1867 } 1761 1868 1762 1869 @Test(.dependencies) func archiveWorktreeConfirmedDelegatesArchiveScript() async { ··· 1989 2096 RepositoriesFeature() 1990 2097 } 1991 2098 store.dependencies.date = .constant(fixedDate) 2099 + // Exhaustive receive closures on the archive chain are too 2100 + // noisy to assert line-by-line — TCA processes synchronous 2101 + // `.send` follow-ups inside the original `send`. Relax 2102 + // exhaustivity and pin the meaningful end state via `#expect`. 2103 + store.exhaustivity = .off 1992 2104 1993 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0, tabId: nil)) { 1994 - $0.archivingWorktreeIDs = [] 1995 - } 1996 - await store.receive(\.archiveWorktreeApply) { 1997 - $0.archivedWorktreeDates[featureWorktree.id] = fixedDate 1998 - } 1999 - await store.receive(\.delegate.repositoriesChanged) 2105 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0, tabId: nil)) 2106 + await store.receive(\.archiveWorktreeApply) 2107 + #expect( 2108 + store.state.sidebar.sections[repository.id]? 2109 + .buckets[.archived]?.items[featureWorktree.id]?.archivedAt == fixedDate 2110 + ) 2111 + #expect(store.state.archivingWorktreeIDs.isEmpty) 2000 2112 } 2001 2113 2002 2114 @Test(.dependencies) func archiveScriptCompletedFailureShowsAlert() async { ··· 2186 2298 RepositoriesFeature() 2187 2299 } 2188 2300 store.dependencies.date = .constant(fixedDate) 2301 + // Exhaustive receive closures on the archive chain are too 2302 + // noisy to assert line-by-line. Relax exhaustivity and pin 2303 + // the meaningful end state via `#expect`. 2189 2304 store.exhaustivity = .off 2190 2305 2191 2306 await store.send(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) 2192 - await store.receive(\.archiveWorktreeApply) { 2193 - $0.archivedWorktreeDates[featureWorktree.id] = fixedDate 2194 - } 2307 + await store.receive(\.archiveWorktreeApply) 2308 + #expect( 2309 + store.state.sidebar.sections[repository.id]? 2310 + .buckets[.archived]?.items[featureWorktree.id]?.archivedAt == fixedDate 2311 + ) 2195 2312 } 2196 2313 2197 2314 @Test func archiveScriptCompletedDoesNotArchiveOnNonZeroExit() async { ··· 2601 2718 let featureWorktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: repoRoot) 2602 2719 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 2603 2720 var state = makeState(repositories: [repository]) 2604 - state.worktreeOrderByRepository[repoRoot] = [featureWorktree.id] 2721 + state.$sidebar.withLock { sidebar in 2722 + sidebar.sections[repository.id] = .init( 2723 + buckets: [.unpinned: .init(items: [featureWorktree.id: .init()])] 2724 + ) 2725 + } 2605 2726 let store = TestStore(initialState: state) { 2606 2727 RepositoriesFeature() 2607 2728 } ··· 2617 2738 let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) 2618 2739 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) 2619 2740 var state = makeState(repositories: [repository]) 2620 - state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] 2741 + state.$sidebar.withLock { sidebar in 2742 + sidebar.sections[repository.id] = .init( 2743 + buckets: [ 2744 + .unpinned: .init( 2745 + items: [featureA.id: .init(), featureB.id: .init()] 2746 + ) 2747 + ] 2748 + ) 2749 + } 2621 2750 let store = TestStore(initialState: state) { 2622 2751 RepositoriesFeature() 2623 2752 } 2624 2753 2625 2754 await store.send(.worktreeNotificationReceived(featureB.id)) { 2626 - $0.worktreeOrderByRepository[repoRoot] = [featureB.id, featureA.id] 2755 + $0.$sidebar.withLock { sidebar in 2756 + sidebar.reorder(bucket: .unpinned, in: repository.id, to: [featureB.id, featureA.id]) 2757 + } 2627 2758 } 2628 2759 #expect(store.state.statusToast == nil) 2629 2760 } ··· 2635 2766 let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) 2636 2767 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) 2637 2768 var state = makeState(repositories: [repository]) 2638 - state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] 2769 + state.$sidebar.withLock { sidebar in 2770 + sidebar.sections[repository.id] = .init( 2771 + buckets: [ 2772 + .unpinned: .init( 2773 + items: [featureA.id: .init(), featureB.id: .init()] 2774 + ) 2775 + ] 2776 + ) 2777 + } 2639 2778 state.moveNotifiedWorktreeToTop = false 2640 2779 let store = TestStore(initialState: state) { 2641 2780 RepositoriesFeature() 2642 2781 } 2643 2782 2644 2783 await store.send(.worktreeNotificationReceived(featureB.id)) 2645 - #expect(store.state.worktreeOrderByRepository[repoRoot] == [featureA.id, featureB.id]) 2784 + #expect( 2785 + Array( 2786 + store.state.sidebar.sections[repository.id]?.buckets[.unpinned]?.items.keys ?? [] 2787 + ) == [featureA.id, featureB.id] 2788 + ) 2646 2789 #expect(store.state.statusToast == nil) 2647 2790 } 2648 2791 ··· 2723 2866 ] 2724 2867 ) 2725 2868 var state = makeState(repositories: [repoA, repoB]) 2726 - state.repositoryOrderIDs = [repoB.id, repoA.id] 2869 + state.$sidebar.withLock { sidebar in 2870 + sidebar.sections[repoB.id] = .init() 2871 + sidebar.sections[repoA.id] = .init() 2872 + } 2727 2873 2728 2874 expectNoDifference( 2729 2875 state.orderedWorktreeRows().map(\.id), ··· 2748 2894 ] 2749 2895 ) 2750 2896 var state = makeState(repositories: [repoA, repoB]) 2751 - state.repositoryOrderIDs = [repoA.id, repoB.id] 2897 + state.$sidebar.withLock { sidebar in 2898 + sidebar.sections[repoA.id] = .init() 2899 + sidebar.sections[repoB.id] = .init() 2900 + } 2752 2901 2753 2902 expectNoDifference( 2754 2903 state.orderedWorktreeRows(includingRepositoryIDs: [repoB.id]).map(\.id), ··· 2762 2911 let repoA = makeRepository(id: "/tmp/repo-a", worktrees: []) 2763 2912 let repoB = makeRepository(id: "/tmp/repo-b", worktrees: []) 2764 2913 var state = makeState(repositories: [repoA, repoB]) 2765 - state.repositoryOrderIDs = [repoB.id] 2914 + state.$sidebar.withLock { sidebar in 2915 + sidebar.sections[repoB.id] = .init() 2916 + } 2766 2917 2767 2918 expectNoDifference( 2768 2919 state.orderedRepositoryRoots().map { $0.path(percentEncoded: false) }, ··· 2783 2934 worktrees: [worktree1, worktree2, worktree3] 2784 2935 ) 2785 2936 var state = makeState(repositories: [repository]) 2786 - state.worktreeOrderByRepository[repoRoot] = [worktree2.id] 2937 + state.$sidebar.withLock { sidebar in 2938 + sidebar.sections[repository.id] = .init( 2939 + buckets: [.unpinned: .init(items: [worktree2.id: .init()])] 2940 + ) 2941 + } 2787 2942 2788 2943 expectNoDifference( 2789 2944 state.orderedUnpinnedWorktreeIDs(in: repository), ··· 2805 2960 worktrees: [worktree1, worktree2, worktree3] 2806 2961 ) 2807 2962 var state = makeState(repositories: [repository]) 2808 - state.worktreeOrderByRepository[repoRoot] = [worktree1.id, worktree2.id, worktree3.id] 2963 + state.$sidebar.withLock { sidebar in 2964 + sidebar.sections[repoRoot] = .init( 2965 + buckets: [ 2966 + .unpinned: .init( 2967 + items: [worktree1.id: .init(), worktree2.id: .init(), worktree3.id: .init()] 2968 + ) 2969 + ] 2970 + ) 2971 + } 2809 2972 let store = TestStore(initialState: state) { 2810 2973 RepositoriesFeature() 2811 2974 } 2812 2975 2813 2976 await store.send(.unpinnedWorktreesMoved(repositoryID: repoRoot, IndexSet(integer: 0), 3)) { 2814 - $0.worktreeOrderByRepository[repoRoot] = [worktree2.id, worktree3.id, worktree1.id] 2977 + $0.$sidebar.withLock { sidebar in 2978 + sidebar.reorder( 2979 + bucket: .unpinned, 2980 + in: repoRoot, 2981 + to: [worktree2.id, worktree3.id, worktree1.id] 2982 + ) 2983 + } 2815 2984 } 2816 2985 } 2817 2986 ··· 2824 2993 let repositoryA = makeRepository(id: repoA, worktrees: [worktreeA1, worktreeA2]) 2825 2994 let repositoryB = makeRepository(id: repoB, worktrees: [worktreeB1]) 2826 2995 var state = makeState(repositories: [repositoryA, repositoryB]) 2827 - state.pinnedWorktreeIDs = [worktreeA1.id, worktreeB1.id, worktreeA2.id] 2996 + state.$sidebar.withLock { sidebar in 2997 + sidebar.sections[repoA] = .init( 2998 + buckets: [ 2999 + .pinned: .init(items: [worktreeA1.id: .init(), worktreeA2.id: .init()]) 3000 + ] 3001 + ) 3002 + sidebar.sections[repoB] = .init( 3003 + buckets: [.pinned: .init(items: [worktreeB1.id: .init()])] 3004 + ) 3005 + } 2828 3006 let store = TestStore(initialState: state) { 2829 3007 RepositoriesFeature() 2830 3008 } 2831 3009 2832 3010 await store.send(.pinnedWorktreesMoved(repositoryID: repoA, IndexSet(integer: 1), 0)) { 2833 - $0.pinnedWorktreeIDs = [worktreeA2.id, worktreeB1.id, worktreeA1.id] 3011 + $0.$sidebar.withLock { sidebar in 3012 + sidebar.reorder(bucket: .pinned, in: repoA, to: [worktreeA2.id, worktreeA1.id]) 3013 + } 2834 3014 } 2835 3015 } 2836 3016 ··· 2862 3042 let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: repoRoot) 2863 3043 let repository = makeRepository(id: repoRoot, worktrees: [worktree1, worktree2]) 2864 3044 var initialState = makeState(repositories: [repository]) 2865 - initialState.worktreeOrderByRepository = [ 2866 - repoRoot: [worktree1.id, worktree2.id] 2867 - ] 3045 + initialState.$sidebar.withLock { sidebar in 3046 + sidebar.sections[repoRoot] = .init( 3047 + buckets: [ 3048 + .unpinned: .init(items: [worktree1.id: .init(), worktree2.id: .init()]) 3049 + ] 3050 + ) 3051 + } 2868 3052 let store = TestStore(initialState: initialState) { 2869 3053 RepositoriesFeature() 2870 3054 } ··· 2884 3068 2885 3069 await store.receive(\.delegate.repositoriesChanged) 2886 3070 expectNoDifference( 2887 - store.state.worktreeOrderByRepository, 2888 - [repoRoot: [worktree1.id, worktree2.id]] 3071 + Array( 3072 + store.state.sidebar.sections[repoRoot]?.buckets[.unpinned]?.items.keys ?? [] 3073 + ), 3074 + [worktree1.id, worktree2.id] 2889 3075 ) 2890 3076 } 2891 3077 ··· 2894 3080 let worktree = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: repoRoot) 2895 3081 let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 2896 3082 var initialState = makeState(repositories: [repository]) 2897 - initialState.archivedWorktreeDates[worktree.id] = Date(timeIntervalSince1970: 1_000_000) 3083 + initialState.$sidebar.withLock { sidebar in 3084 + sidebar.insert( 3085 + worktree: worktree.id, 3086 + in: repository.id, 3087 + bucket: .archived, 3088 + item: .init(archivedAt: Date(timeIntervalSince1970: 1_000_000)) 3089 + ) 3090 + } 2898 3091 let store = TestStore(initialState: initialState) { 2899 3092 RepositoriesFeature() 2900 3093 } ··· 2989 3182 progress: WorktreeCreationProgress(stage: .choosingWorktreeName) 2990 3183 ) 2991 3184 ] 2992 - initialState.pinnedWorktreeIDs = [removedWorktree.id] 3185 + initialState.$sidebar.withLock { sidebar in 3186 + sidebar.sections[repository.id] = .init( 3187 + buckets: [.pinned: .init(items: [removedWorktree.id: .init()])] 3188 + ) 3189 + } 2993 3190 initialState.worktreeInfoByID = [ 2994 3191 removedWorktree.id: WorktreeInfoEntry(addedLines: 1, removedLines: 2, pullRequest: nil) 2995 3192 ] ··· 3011 3208 $0.pendingSetupScriptWorktreeIDs = [] 3012 3209 $0.pendingTerminalFocusWorktreeIDs = [] 3013 3210 $0.pendingWorktrees = [] 3014 - $0.pinnedWorktreeIDs = [] 3015 3211 $0.worktreeInfoByID = [:] 3016 3212 $0.repositories = [updatedRepository] 3213 + $0.$sidebar.withLock { sidebar in 3214 + sidebar.removeAnywhere(worktree: removedWorktree.id, in: repository.id) 3215 + } 3017 3216 } 3018 3217 await store.receive(\.delegate.repositoriesChanged) 3019 3218 await store.receive(\.reloadRepositories) ··· 3120 3319 RepositoriesFeature() 3121 3320 } 3122 3321 store.dependencies.date = .constant(fixedDate) 3322 + // Exhaustive receive closures on the archive chain are too 3323 + // noisy to assert line-by-line. Relax exhaustivity and pin 3324 + // the meaningful end state via `#expect`. 3325 + store.exhaustivity = .off 3123 3326 let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: featureWorktree.name) 3124 3327 3125 3328 await store.send( ··· 3127 3330 repositoryID: repository.id, 3128 3331 pullRequestsByWorktreeID: [featureWorktree.id: mergedPullRequest] 3129 3332 ) 3130 - ) { 3131 - $0.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( 3132 - addedLines: nil, 3133 - removedLines: nil, 3134 - pullRequest: mergedPullRequest 3135 - ) 3136 - } 3137 - await store.receive(\.archiveWorktreeConfirmed) 3138 - await store.receive(\.archiveWorktreeApply) { 3139 - $0.archivedWorktreeDates[featureWorktree.id] = fixedDate 3140 - } 3141 - await store.receive(\.delegate.repositoriesChanged) 3333 + ) 3334 + await store.receive(\.archiveWorktreeApply) 3335 + #expect( 3336 + store.state.sidebar.sections[repository.id]? 3337 + .buckets[.archived]?.items[featureWorktree.id]?.archivedAt == fixedDate 3338 + ) 3339 + #expect( 3340 + store.state.worktreeInfoByID[featureWorktree.id]?.pullRequest == mergedPullRequest 3341 + ) 3142 3342 } 3143 3343 3144 3344 @Test func repositoryPullRequestsLoadedSkipsAutoArchiveForMainWorktree() async { ··· 3243 3443 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3244 3444 var state = makeState(repositories: [repository]) 3245 3445 state.mergedWorktreeAction = .delete 3246 - state.archivedWorktreeDates[featureWorktree.id] = Date(timeIntervalSince1970: 1_000_000) 3446 + state.$sidebar.withLock { sidebar in 3447 + sidebar.insert( 3448 + worktree: featureWorktree.id, 3449 + in: repository.id, 3450 + bucket: .archived, 3451 + item: .init(archivedAt: Date(timeIntervalSince1970: 1_000_000)) 3452 + ) 3453 + } 3247 3454 let store = TestStore(initialState: state) { 3248 3455 RepositoriesFeature() 3249 3456 } ··· 3811 4018 let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3812 4019 var state = makeState(repositories: [repository]) 3813 4020 state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3814 - state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 4021 + state.$sidebar.withLock { sidebar in 4022 + sidebar.insert( 4023 + worktree: featureWorktree.id, 4024 + in: repository.id, 4025 + bucket: .archived, 4026 + item: .init(archivedAt: eightDaysAgo) 4027 + ) 4028 + } 3815 4029 let store = TestStore(initialState: state) { 3816 4030 RepositoriesFeature() 3817 4031 } ··· 3835 4049 let threeDaysAgo = fixedDate.addingTimeInterval(-3 * 86400) 3836 4050 var state = makeState(repositories: [repository]) 3837 4051 state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3838 - state.archivedWorktreeDates[featureWorktree.id] = threeDaysAgo 4052 + state.$sidebar.withLock { sidebar in 4053 + sidebar.insert( 4054 + worktree: featureWorktree.id, 4055 + in: repository.id, 4056 + bucket: .archived, 4057 + item: .init(archivedAt: threeDaysAgo) 4058 + ) 4059 + } 3839 4060 let store = TestStore(initialState: state) { 3840 4061 RepositoriesFeature() 3841 4062 } ··· 3852 4073 let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3853 4074 var state = makeState(repositories: [repository]) 3854 4075 state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3855 - state.archivedWorktreeDates[mainWorktree.id] = eightDaysAgo 4076 + state.$sidebar.withLock { sidebar in 4077 + sidebar.insert( 4078 + worktree: mainWorktree.id, 4079 + in: repository.id, 4080 + bucket: .archived, 4081 + item: .init(archivedAt: eightDaysAgo) 4082 + ) 4083 + } 3856 4084 let store = TestStore(initialState: state) { 3857 4085 RepositoriesFeature() 3858 4086 } ··· 3874 4102 let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3875 4103 var state = makeState(repositories: [repository]) 3876 4104 state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3877 - state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 4105 + state.$sidebar.withLock { sidebar in 4106 + sidebar.insert( 4107 + worktree: featureWorktree.id, 4108 + in: repository.id, 4109 + bucket: .archived, 4110 + item: .init(archivedAt: eightDaysAgo) 4111 + ) 4112 + } 3878 4113 state.deletingWorktreeIDs = [featureWorktree.id] 3879 4114 let store = TestStore(initialState: state) { 3880 4115 RepositoriesFeature() ··· 3896 4131 let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3897 4132 let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3898 4133 var state = makeState(repositories: [repository]) 3899 - state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 4134 + state.$sidebar.withLock { sidebar in 4135 + sidebar.insert( 4136 + worktree: featureWorktree.id, 4137 + in: repository.id, 4138 + bucket: .archived, 4139 + item: .init(archivedAt: eightDaysAgo) 4140 + ) 4141 + } 3900 4142 let store = TestStore(initialState: state) { 3901 4143 RepositoriesFeature() 3902 4144 } ··· 3917 4159 let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3918 4160 let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3919 4161 var state = makeState(repositories: [repository]) 3920 - state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 4162 + state.$sidebar.withLock { sidebar in 4163 + sidebar.insert( 4164 + worktree: featureWorktree.id, 4165 + in: repository.id, 4166 + bucket: .archived, 4167 + item: .init(archivedAt: eightDaysAgo) 4168 + ) 4169 + } 3921 4170 let store = TestStore(initialState: state) { 3922 4171 RepositoriesFeature() 3923 4172 } ··· 3944 4193 let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3945 4194 var state = makeState(repositories: [repository]) 3946 4195 state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3947 - state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 4196 + state.$sidebar.withLock { sidebar in 4197 + sidebar.insert( 4198 + worktree: featureWorktree.id, 4199 + in: repository.id, 4200 + bucket: .archived, 4201 + item: .init(archivedAt: eightDaysAgo) 4202 + ) 4203 + } 3948 4204 state.deleteScriptWorktreeIDs = [featureWorktree.id] 3949 4205 let store = TestStore(initialState: state) { 3950 4206 RepositoriesFeature() ··· 3967 4223 let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3968 4224 var state = makeState(repositories: [repository]) 3969 4225 state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3970 - state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 4226 + state.$sidebar.withLock { sidebar in 4227 + sidebar.insert( 4228 + worktree: featureWorktree.id, 4229 + in: repository.id, 4230 + bucket: .archived, 4231 + item: .init(archivedAt: eightDaysAgo) 4232 + ) 4233 + } 3971 4234 state.archivingWorktreeIDs = [featureWorktree.id] 3972 4235 let store = TestStore(initialState: state) { 3973 4236 RepositoriesFeature() ··· 3990 4253 let exactlySevenDaysAgo = fixedDate.addingTimeInterval(-7 * 86400) 3991 4254 var state = makeState(repositories: [repository]) 3992 4255 state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3993 - state.archivedWorktreeDates[featureWorktree.id] = exactlySevenDaysAgo 4256 + state.$sidebar.withLock { sidebar in 4257 + sidebar.insert( 4258 + worktree: featureWorktree.id, 4259 + in: repository.id, 4260 + bucket: .archived, 4261 + item: .init(archivedAt: exactlySevenDaysAgo) 4262 + ) 4263 + } 3994 4264 let store = TestStore(initialState: state) { 3995 4265 RepositoriesFeature() 3996 4266 } ··· 4014 4284 let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 4015 4285 var state = makeState(repositories: [repository]) 4016 4286 state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 4017 - state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 4287 + state.$sidebar.withLock { sidebar in 4288 + sidebar.insert( 4289 + worktree: featureWorktree.id, 4290 + in: repository.id, 4291 + bucket: .archived, 4292 + item: .init(archivedAt: eightDaysAgo) 4293 + ) 4294 + } 4018 4295 let store = TestStore(initialState: state) { 4019 4296 RepositoriesFeature() 4020 4297 } ··· 4054 4331 let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 4055 4332 var state = makeState(repositories: [repository]) 4056 4333 state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 4057 - state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 4334 + state.$sidebar.withLock { sidebar in 4335 + sidebar.insert( 4336 + worktree: featureWorktree.id, 4337 + in: repository.id, 4338 + bucket: .archived, 4339 + item: .init(archivedAt: eightDaysAgo) 4340 + ) 4341 + } 4058 4342 let store = TestStore(initialState: state) { 4059 4343 RepositoriesFeature() 4060 4344 } ··· 4197 4481 let repo3 = makeRepository(id: "/tmp/repo3", worktrees: [wt3]) 4198 4482 var state = makeState(repositories: [repo1, repo2, repo3]) 4199 4483 state.selection = .worktree(wt1.id) 4200 - state.$collapsedRepositoryIDs.withLock { $0 = [repo2.id] } 4484 + state.$sidebar.withLock { sidebar in 4485 + sidebar.sections[repo2.id, default: .init()].collapsed = true 4486 + } 4201 4487 let store = TestStore(initialState: state) { 4202 4488 RepositoriesFeature() 4203 4489 } ··· 4219 4505 let repo3 = makeRepository(id: "/tmp/repo3", worktrees: [wt3]) 4220 4506 var state = makeState(repositories: [repo1, repo2, repo3]) 4221 4507 state.selection = .worktree(wt3.id) 4222 - state.$collapsedRepositoryIDs.withLock { $0 = [repo2.id] } 4508 + state.$sidebar.withLock { sidebar in 4509 + sidebar.sections[repo2.id, default: .init()].collapsed = true 4510 + } 4223 4511 let store = TestStore(initialState: state) { 4224 4512 RepositoriesFeature() 4225 4513 } ··· 4237 4525 let repo1 = makeRepository(id: "/tmp/repo1", worktrees: [wt1]) 4238 4526 var state = makeState(repositories: [repo1]) 4239 4527 state.selection = .worktree(wt1.id) 4240 - state.$collapsedRepositoryIDs.withLock { $0 = [repo1.id] } 4528 + state.$sidebar.withLock { sidebar in 4529 + sidebar.sections[repo1.id, default: .init()].collapsed = true 4530 + } 4241 4531 let store = TestStore(initialState: state) { 4242 4532 RepositoriesFeature() 4243 4533 } ··· 4250 4540 let repo1 = makeRepository(id: "/tmp/repo1", worktrees: [wt1]) 4251 4541 var state = makeState(repositories: [repo1]) 4252 4542 state.selection = .worktree(wt1.id) 4253 - state.$collapsedRepositoryIDs.withLock { $0 = [repo1.id] } 4543 + state.$sidebar.withLock { sidebar in 4544 + sidebar.sections[repo1.id, default: .init()].collapsed = true 4545 + } 4254 4546 let store = TestStore(initialState: state) { 4255 4547 RepositoriesFeature() 4256 4548 } ··· 4267 4559 let repo3 = makeRepository(id: "/tmp/repo3", worktrees: [wt3]) 4268 4560 var state = makeState(repositories: [repo1, repo2, repo3]) 4269 4561 state.selection = .worktree(wt3.id) 4270 - state.$collapsedRepositoryIDs.withLock { $0 = [repo2.id] } 4562 + state.$sidebar.withLock { sidebar in 4563 + sidebar.sections[repo2.id, default: .init()].collapsed = true 4564 + } 4271 4565 let store = TestStore(initialState: state) { 4272 4566 RepositoriesFeature() 4273 4567 } ··· 4444 4738 ) 4445 4739 4446 4740 var state = RepositoriesFeature.State() 4447 - state.lastFocusedWorktreeID = worktreeB.id 4741 + state.$sidebar.withLock { $0.focusedWorktreeID = worktreeB.id } 4448 4742 state.shouldRestoreLastFocusedWorktree = true 4449 4743 4450 4744 let store = TestStore(initialState: state) {
+5 -29
supacodeTests/RepositoryPersistenceClientTests.swift
··· 45 45 #expect(result.isEmpty) 46 46 } 47 47 48 - // MARK: - Legacy Migration 49 - 50 - @Test(.dependencies) func loadArchivedWorktreeDatesMigratesLegacyKey() async { 51 - let client = RepositoryPersistenceClient.liveValue 52 - @Shared(.appStorage("archivedWorktreeIDs")) var legacyIDs: [Worktree.ID] = [] 53 - @Shared(.appStorage(archivedWorktreeDatesStorageKey)) var dates: [Worktree.ID: Date] = [:] 54 - $legacyIDs.withLock { $0 = ["/tmp/repo/feature", "/tmp/repo/bugfix"] } 55 - $dates.withLock { $0 = [:] } 56 - 57 - let result = await client.loadArchivedWorktreeDates() 58 - #expect(result.count == 2) 59 - #expect(result["/tmp/repo/feature"] != nil) 60 - #expect(result["/tmp/repo/bugfix"] != nil) 61 - // Legacy key should be cleared after migration. 62 - #expect(legacyIDs.isEmpty) 63 - } 64 - 65 - // MARK: - Roots and Pins 48 + // MARK: - Roots 66 49 67 - @Test(.dependencies) func savesAndLoadsRootsAndPins() async throws { 50 + @Test(.dependencies) func savesAndLoadsRoots() async throws { 68 51 let storage = SettingsTestStorage() 69 52 70 53 withDependencies { ··· 77 60 } 78 61 79 62 let client = RepositoryPersistenceClient.liveValue 80 - let result = await withDependencies { 63 + let roots = await withDependencies { 81 64 $0.settingsFileStorage = storage.storage 82 65 } operation: { 83 66 await client.saveRoots([ ··· 85 68 "/tmp/repo-a", 86 69 "/tmp/repo-b/../repo-b", 87 70 ]) 88 - await client.savePinnedWorktreeIDs([ 89 - "/tmp/repo-a/wt-1", 90 - "/tmp/repo-a/wt-1", 91 - ]) 92 - let roots = await client.loadRoots() 93 - let pinned = await client.loadPinnedWorktreeIDs() 94 - return (roots: roots, pinned: pinned) 71 + return await client.loadRoots() 95 72 } 96 73 97 - #expect(result.roots == ["/tmp/repo-a", "/tmp/repo-b"]) 98 - #expect(result.pinned == ["/tmp/repo-a/wt-1"]) 74 + #expect(roots == ["/tmp/repo-a", "/tmp/repo-b"]) 99 75 100 76 let finalSettings: SettingsFile = withDependencies { 101 77 $0.settingsFileStorage = storage.storage
+56 -8
supacodeTests/SettingsFeatureTests.swift
··· 593 593 } 594 594 store.dependencies.date = .constant(fixedDate) 595 595 store.dependencies.archivedWorktreeDatesClient = ArchivedWorktreeDatesClient( 596 - load: { ["/tmp/repo/feature": tenDaysAgo] }, 597 - save: { _ in } 596 + load: { [tenDaysAgo] }, 598 597 ) 599 598 600 599 await store.send(.requestAutoDeleteDaysChange(.sevenDays)) ··· 627 626 } 628 627 store.dependencies.date = .constant(fixedDate) 629 628 store.dependencies.archivedWorktreeDatesClient = ArchivedWorktreeDatesClient( 630 - load: { ["/tmp/repo/feature": oneDayAgo] }, 631 - save: { _ in } 629 + load: { [oneDayAgo] }, 632 630 ) 633 631 634 632 await store.send(.requestAutoDeleteDaysChange(.sevenDays)) ··· 676 674 } 677 675 store.dependencies.date = .constant(fixedDate) 678 676 store.dependencies.archivedWorktreeDatesClient = ArchivedWorktreeDatesClient( 679 - load: { ["/tmp/repo/feature": tenDaysAgo, "/tmp/repo/bugfix": twelveDaysAgo] }, 680 - save: { _ in } 677 + load: { [tenDaysAgo, twelveDaysAgo] }, 681 678 ) 682 679 683 680 await store.send(.requestAutoDeleteDaysChange(.sevenDays)) ··· 735 732 } 736 733 store.dependencies.date = .constant(fixedDate) 737 734 store.dependencies.archivedWorktreeDatesClient = ArchivedWorktreeDatesClient( 738 - load: { [:] }, 739 - save: { _ in } 735 + load: { [] }, 740 736 ) 741 737 742 738 await store.send(.requestAutoDeleteDaysChange(.sevenDays)) ··· 744 740 $0.autoDeleteArchivedWorktreesAfterDays = .sevenDays 745 741 } 746 742 await store.receive(\.delegate.settingsChanged) 743 + } 744 + 745 + /// Regression test for C7: after the sidebar refactor, the 746 + /// auto-delete preflight used to read timestamps from the legacy 747 + /// `@Shared(.appStorage("archivedWorktreeDates"))` dict via 748 + /// `ArchivedWorktreeDatesClient.liveValue`. The sidebar migrator 749 + /// wipes that key on first launch, so the affected count would 750 + /// drop to 0, the destructive-confirmation alert would silently 751 + /// skip, and the next reducer pass would read from the new 752 + /// `@Shared(.sidebar)` and delete everything older than the 753 + /// cutoff without user consent. The fix routes the affected 754 + /// count through the same canonical `@Shared(.sidebar)` source 755 + /// the sweep reads from — so if timestamps exist they surface 756 + /// in the alert, and if they don't no alert is skipped-past. 757 + @Test(.dependencies) 758 + func requestAutoDeleteDaysChangeReadsTimestampsFromCanonicalSidebarBucket() async { 759 + var settings = GlobalSettings.default 760 + settings.autoDeleteArchivedWorktreesAfterDays = .fourteenDays 761 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 762 + let thirtyDaysAgoA = fixedDate.addingTimeInterval(-30 * 86400) 763 + let thirtyDaysAgoB = fixedDate.addingTimeInterval(-31 * 86400) 764 + let thirtyDaysAgoC = fixedDate.addingTimeInterval(-32 * 86400) 765 + let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { 766 + SettingsFeature() 767 + } 768 + store.dependencies.date = .constant(fixedDate) 769 + store.dependencies.archivedWorktreeDatesClient = ArchivedWorktreeDatesClient( 770 + load: { [thirtyDaysAgoA, thirtyDaysAgoB, thirtyDaysAgoC] }, 771 + ) 772 + 773 + await store.send(.requestAutoDeleteDaysChange(.sevenDays)) 774 + // The period must NOT be mutated pre-confirmation — otherwise 775 + // the destructive change silently races the next sweep. 776 + #expect(store.state.autoDeleteArchivedWorktreesAfterDays == .fourteenDays) 777 + await store.receive(\.resolvedAutoDeleteAffectedCount) { 778 + $0.alert = AlertState { 779 + TextState("Delete 3 archived worktrees?") 780 + } actions: { 781 + ButtonState(role: .destructive, action: .confirmAutoDeleteDaysChange(.sevenDays)) { 782 + TextState("Delete") 783 + } 784 + ButtonState(role: .cancel, action: .dismiss) { 785 + TextState("Cancel") 786 + } 787 + } message: { 788 + TextState( 789 + "3 archived worktrees will be deleted immediately because " 790 + + "they were archived more than 7 days ago." 791 + ) 792 + } 793 + } 794 + #expect(store.state.autoDeleteArchivedWorktreesAfterDays == .fourteenDays) 747 795 } 748 796 749 797 // MARK: - GlobalSettings Decoding Validation
+47
supacodeTests/SidebarPersistenceKeyTests.swift
··· 1 + import Dependencies 2 + import DependenciesTestSupport 3 + import Foundation 4 + import OrderedCollections 5 + import Sharing 6 + import Testing 7 + 8 + @testable import SupacodeSettingsShared 9 + @testable import supacode 10 + 11 + @MainActor 12 + struct SidebarPersistenceKeyTests { 13 + @Test func corruptFileIsRenamedBeforeFallback() async throws { 14 + // Write the corrupt bytes to an isolated temp directory so the 15 + // test never touches the user's real `~/.supacode/sidebar.json`. 16 + // The live `\.settingsFileStorage` is used because we want to 17 + // exercise the real `moveItem` rename path; `\.sidebarFileURL` 18 + // is overridden to point at our temp file so the SharedKey 19 + // reads/writes there exclusively. 20 + let fileManager = FileManager.default 21 + let sandbox = fileManager.temporaryDirectory 22 + .appending(path: "SidebarPersistenceKeyTests-\(UUID().uuidString)", directoryHint: .isDirectory) 23 + try fileManager.createDirectory(at: sandbox, withIntermediateDirectories: true) 24 + defer { 25 + try? fileManager.removeItem(at: sandbox) 26 + } 27 + let sidebarURL = sandbox.appending(path: "sidebar.json", directoryHint: .notDirectory) 28 + try Data("this-is-not-json".utf8).write(to: sidebarURL) 29 + 30 + await withDependencies { 31 + $0.settingsFileStorage = SettingsFileStorageKey.liveValue 32 + $0.sidebarFileURL = sidebarURL 33 + } operation: { 34 + // Touching `@Shared(.sidebar)` triggers `SidebarKey.load`, 35 + // which on decode failure must rename the corrupt file. 36 + @Shared(.sidebar) var sidebar 37 + _ = sidebar 38 + } 39 + 40 + #expect(!fileManager.fileExists(atPath: sidebarURL.path(percentEncoded: false))) 41 + let entries = try fileManager.contentsOfDirectory( 42 + at: sandbox, includingPropertiesForKeys: nil 43 + ) 44 + let renamed = entries.first { $0.lastPathComponent.hasPrefix("sidebar.json.corrupt-") } 45 + #expect(renamed != nil) 46 + } 47 + }
+848
supacodeTests/SidebarPersistenceMigratorTests.swift
··· 1 + import Dependencies 2 + import DependenciesTestSupport 3 + import Foundation 4 + import OrderedCollections 5 + import Sharing 6 + import Testing 7 + 8 + @testable import SupacodeSettingsShared 9 + @testable import supacode 10 + 11 + @MainActor 12 + struct SidebarPersistenceMigratorTests { 13 + @Test(.dependencies) func noopWhenSidebarFileAlreadyExists() throws { 14 + let storage = InMemorySettingsFileStorage() 15 + // Seed a migrated-schema file so the idempotency gate short- 16 + // circuits. An empty `{}` would decode with `schemaVersion == 0` 17 + // and (correctly) trigger a re-migration. 18 + let encoder = JSONEncoder() 19 + var seeded = SidebarState() 20 + seeded.schemaVersion = 1 21 + let seededBytes = try encoder.encode(seeded) 22 + try storage.save(seededBytes, SupacodePaths.sidebarURL) 23 + let existingBytes = try storage.load(SupacodePaths.sidebarURL) 24 + 25 + withDependencies { 26 + $0.settingsFileStorage = SettingsFileStorage( 27 + load: { try storage.load($0) }, 28 + save: { try storage.save($0, $1) } 29 + ) 30 + $0.defaultAppStorage = UserDefaults(suiteName: "\(#function).\(UUID().uuidString)")! 31 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 32 + } operation: { 33 + @Shared(.appStorage("repositoryOrderIDs")) var legacyOrder: [String] = [] 34 + $legacyOrder.withLock { $0 = ["/tmp/repo-a"] } 35 + 36 + SidebarPersistenceMigrator.migrateIfNeeded( 37 + fileExists: { _ in true }, 38 + readFile: { try? storage.load($0) } 39 + ) 40 + 41 + // File untouched — still the bytes we seeded — and legacy 42 + // UserDefaults blob untouched since the migrator short- 43 + // circuited on the schemaVersion idempotency gate. 44 + #expect((try? storage.load(SupacodePaths.sidebarURL)) == existingBytes) 45 + #expect(legacyOrder == ["/tmp/repo-a"]) 46 + } 47 + } 48 + 49 + @Test(.dependencies) func migratesCollapsePinOrderArchiveFocus() throws { 50 + let storage = InMemorySettingsFileStorage() 51 + let archivedAt = Date(timeIntervalSince1970: 1_000_000) 52 + let suiteName = "\(#function).\(UUID().uuidString)" 53 + 54 + try withDependencies { 55 + $0.settingsFileStorage = SettingsFileStorage( 56 + load: { try storage.load($0) }, 57 + save: { try storage.save($0, $1) } 58 + ) 59 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 60 + // Migrator now stamps `Date.now` onto pre-#214 archived 61 + // straggler entries via `@Dependency(\.date.now)`; pin a 62 + // fixed instant here so the migration path doesn't trip the 63 + // "live dependency accessed from test" guard. 64 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 65 + } operation: { 66 + @Shared(.appStorage("repositoryOrderIDs")) var legacyOrder: [String] = [] 67 + @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var legacyCollapsed: [String] = [] 68 + @Shared(.appStorage("worktreeOrderByRepository")) var legacyWorktreeOrder: [String: [String]] = [:] 69 + @Shared(.appStorage("lastFocusedWorktreeID")) var legacyFocus: String? 70 + @Shared(.appStorage("archivedWorktreeDates")) var legacyArchived: [String: Date] = [:] 71 + @Shared(.settingsFile) var settings 72 + 73 + $legacyOrder.withLock { 74 + $0 = ["/tmp/repo-a", "/tmp/repo-b"] 75 + } 76 + $legacyCollapsed.withLock { 77 + $0 = ["/tmp/repo-b"] 78 + } 79 + $legacyWorktreeOrder.withLock { 80 + $0 = [ 81 + "/tmp/repo-a": ["/tmp/repo-a/wt-1", "/tmp/repo-a/wt-2"], 82 + "/tmp/repo-b": ["/tmp/repo-b/wt-3"], 83 + ] 84 + } 85 + $legacyFocus.withLock { $0 = "/tmp/repo-a/wt-2" } 86 + $legacyArchived.withLock { $0 = ["/tmp/repo-b/wt-3": archivedAt] } 87 + $settings.withLock { 88 + $0.pinnedWorktreeIDs = ["/tmp/repo-a/wt-1"] 89 + $0.repositoryRoots = ["/tmp/repo-a", "/tmp/repo-b"] 90 + } 91 + 92 + SidebarPersistenceMigrator.migrateIfNeeded(fileExists: { _ in false }) 93 + 94 + // 1. The new `sidebar.json` file was written. 95 + let data = try storage.load(SupacodePaths.sidebarURL) 96 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 97 + 98 + let repoA = "/tmp/repo-a" 99 + let repoB = "/tmp/repo-b" 100 + // Sections preserve the legacy repo-order. 101 + #expect(Array(migrated.sections.keys) == [repoA, repoB]) 102 + // repo-b is collapsed; repo-a is not. 103 + #expect(migrated.sections[repoA]?.collapsed == false) 104 + #expect(migrated.sections[repoB]?.collapsed == true) 105 + // wt-1 routes to `.pinned`; wt-2 stays in `.unpinned`. 106 + let repoAPinned = Array(migrated.sections[repoA]?.buckets[.pinned]?.items.keys ?? []) 107 + let repoAUnpinned = Array(migrated.sections[repoA]?.buckets[.unpinned]?.items.keys ?? []) 108 + #expect(repoAPinned == ["/tmp/repo-a/wt-1"]) 109 + #expect(repoAUnpinned == ["/tmp/repo-a/wt-2"]) 110 + // wt-3 routes to `.archived` (timestamp wins over `.unpinned`). 111 + #expect(migrated.sections[repoB]?.buckets[.unpinned]?.items["/tmp/repo-b/wt-3"] == nil) 112 + #expect(migrated.sections[repoB]?.buckets[.archived]?.items["/tmp/repo-b/wt-3"]?.archivedAt == archivedAt) 113 + // Focus carries through. 114 + #expect(migrated.focusedWorktreeID == "/tmp/repo-a/wt-2") 115 + 116 + // 2. Legacy sources cleared. 117 + #expect(legacyOrder.isEmpty) 118 + #expect(legacyCollapsed.isEmpty) 119 + #expect(legacyWorktreeOrder.isEmpty) 120 + #expect(legacyFocus == nil) 121 + #expect(legacyArchived.isEmpty) 122 + #expect(settings.pinnedWorktreeIDs.isEmpty) 123 + } 124 + } 125 + 126 + @Test(.dependencies) func rescuesOrphanPinnedViaPathPrefixMatch() throws { 127 + let storage = InMemorySettingsFileStorage() 128 + let suiteName = "\(#function).\(UUID().uuidString)" 129 + 130 + try withDependencies { 131 + $0.settingsFileStorage = SettingsFileStorage( 132 + load: { try storage.load($0) }, 133 + save: { try storage.save($0, $1) } 134 + ) 135 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 136 + // Migrator now stamps `Date.now` onto pre-#214 archived 137 + // straggler entries via `@Dependency(\.date.now)`; pin a 138 + // fixed instant here so the migration path doesn't trip the 139 + // "live dependency accessed from test" guard. 140 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 141 + } operation: { 142 + @Shared(.settingsFile) var settings 143 + $settings.withLock { 144 + // No legacy row-order; just roots + a pinned ID. 145 + $0.repositoryRoots = ["/tmp/repo-a"] 146 + $0.pinnedWorktreeIDs = ["/tmp/repo-a/feature"] 147 + } 148 + 149 + SidebarPersistenceMigrator.migrateIfNeeded(fileExists: { _ in false }) 150 + 151 + let data = try storage.load(SupacodePaths.sidebarURL) 152 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 153 + #expect(migrated.sections["/tmp/repo-a"]?.buckets[.pinned]?.items["/tmp/repo-a/feature"] != nil) 154 + } 155 + } 156 + 157 + @Test func rescuePrefixMatchPicksLongestNestedRoot() { 158 + // `.local(/tmp/outer/inner/wt-1)` has two candidate roots; 159 + // the longest-wins rule must pick `/tmp/outer/inner` so 160 + // nested repo registrations don't collapse into the outer. 161 + // `repositoryID(...)` now expects pre-normalised inputs on 162 + // both sides — route the raw paths through 163 + // `RepositoryPathNormalizer.normalize(_:)` first so the test 164 + // pins the canonical-in/canonical-out contract end to end. 165 + let worktreeID = RepositoryPathNormalizer.normalize("/tmp/outer/inner/wt-1")! 166 + let outer = RepositoryPathNormalizer.normalize(["/tmp/outer", "/tmp/outer/inner"]) 167 + let reversed = RepositoryPathNormalizer.normalize(["/tmp/outer/inner", "/tmp/outer"]) 168 + let expected = RepositoryPathNormalizer.normalize("/tmp/outer/inner")! 169 + for roots in [outer, reversed] { 170 + let candidates = roots.map { (candidate: $0, owningRoot: $0) } 171 + let resolved = SidebarPersistenceMigrator.repositoryID( 172 + owningWorktreeID: worktreeID, 173 + amongLegacyRoots: candidates 174 + ) 175 + #expect(resolved == expected) 176 + } 177 + } 178 + 179 + @Test func rescuePrefixMatchRejectsNonParentPrefix() { 180 + // "/tmp/rep" is a string-prefix of "/tmp/repo" but NOT a 181 + // parent directory — the trailing-slash guard must reject 182 + // this match. Pre-normalise both sides to match the new 183 + // canonical-input contract of `repositoryID(...)`. 184 + let worktreeID = RepositoryPathNormalizer.normalize("/tmp/repo/wt-1")! 185 + let roots = RepositoryPathNormalizer.normalize(["/tmp/rep"]) 186 + let candidates = roots.map { (candidate: $0, owningRoot: $0) } 187 + let resolved = SidebarPersistenceMigrator.repositoryID( 188 + owningWorktreeID: worktreeID, 189 + amongLegacyRoots: candidates 190 + ) 191 + #expect(resolved == nil) 192 + } 193 + 194 + @Test func rescuePrefixMatchHandlesTrailingSlashRoot() { 195 + // `URL(filePath:).standardizedFileURL.path(percentEncoded:)` 196 + // preserves trailing slashes for directory-styled inputs. 197 + // The migrator must strip them before building the guard 198 + // prefix; otherwise the concatenation `"/tmp/repo-a/" + "/"` 199 + // produces `"//"` which never matches a worktree path. 200 + // `repositoryID(...)` now returns the caller-supplied 201 + // `owningRoot` as-is once a candidate matches — live callers 202 + // always pass the canonical `Repository.ID` there, so echoing 203 + // it unchanged keeps downstream section-key lookups honest. 204 + let worktreeID = RepositoryPathNormalizer.normalize("/tmp/repo-a/wt-1")! 205 + let roots = RepositoryPathNormalizer.normalize(["/tmp/repo-a/"]) 206 + let candidates = roots.map { (candidate: $0, owningRoot: $0) } 207 + let resolved = SidebarPersistenceMigrator.repositoryID( 208 + owningWorktreeID: worktreeID, 209 + amongLegacyRoots: candidates 210 + ) 211 + #expect(resolved == roots.first) 212 + } 213 + 214 + @Test func normalizerRejectsEmptyAndWhitespaceAndCollapsesDotComponents() { 215 + // Replaces the retired `translate(_:)` helper: pin the new 216 + // canonical single-path normaliser's rejection semantics 217 + // (empty / whitespace-only → `nil`) plus the standardised- 218 + // path collapsing that `URL(fileURLWithPath:).standardizedFileURL` 219 + // performs. `RepositoryPathNormalizer.normalize(_:)` does NOT 220 + // inspect scheme strings — `URL(fileURLWithPath:)` treats 221 + // `"custom://whatever"` as a literal filesystem path, so the 222 + // old "reject unknown schemes" guarantee does not survive the 223 + // rewrite. Documented here so future readers don't assume the 224 + // old behaviour still holds. 225 + #expect(RepositoryPathNormalizer.normalize("/tmp/repo-a") == "/tmp/repo-a") 226 + #expect(RepositoryPathNormalizer.normalize("/tmp/./repo-a") == "/tmp/repo-a") 227 + #expect(RepositoryPathNormalizer.normalize("") == nil) 228 + #expect(RepositoryPathNormalizer.normalize(" ") == nil) 229 + #expect(RepositoryPathNormalizer.normalize("\n\t") == nil) 230 + } 231 + 232 + @Test(.dependencies) func migrationStampsSchemaVersion1OnWriteSuccess() throws { 233 + let storage = InMemorySettingsFileStorage() 234 + let suiteName = "\(#function).\(UUID().uuidString)" 235 + 236 + try withDependencies { 237 + $0.settingsFileStorage = SettingsFileStorage( 238 + load: { try storage.load($0) }, 239 + save: { try storage.save($0, $1) } 240 + ) 241 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 242 + // Migrator now stamps `Date.now` onto pre-#214 archived 243 + // straggler entries via `@Dependency(\.date.now)`; pin a 244 + // fixed instant here so the migration path doesn't trip the 245 + // "live dependency accessed from test" guard. 246 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 247 + } operation: { 248 + @Shared(.appStorage("repositoryOrderIDs")) var legacyOrder: [String] = [] 249 + $legacyOrder.withLock { $0 = ["/tmp/repo-a"] } 250 + 251 + SidebarPersistenceMigrator.migrateIfNeeded( 252 + fileExists: { _ in false }, 253 + readFile: { try? storage.load($0) } 254 + ) 255 + 256 + let data = try storage.load(SupacodePaths.sidebarURL) 257 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 258 + #expect(migrated.schemaVersion == 1) 259 + } 260 + } 261 + 262 + @Test(.dependencies) func migratorRerunsWhenFileHasSchemaVersionZero() throws { 263 + let storage = InMemorySettingsFileStorage() 264 + // Pre-seed `sidebar.json` with an empty `SidebarState()` — its 265 + // `schemaVersion` defaults to `0`, simulating a file that was 266 + // created by a `@Shared(.sidebar)` mutation before the migrator 267 + // ever landed its write (e.g. because a previous migration run 268 + // crashed between allocating the shared state and persisting). 269 + let encoder = JSONEncoder() 270 + let priorBytes = try encoder.encode(SidebarState()) 271 + try storage.save(priorBytes, SupacodePaths.sidebarURL) 272 + let suiteName = "\(#function).\(UUID().uuidString)" 273 + 274 + try withDependencies { 275 + $0.settingsFileStorage = SettingsFileStorage( 276 + load: { try storage.load($0) }, 277 + save: { try storage.save($0, $1) } 278 + ) 279 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 280 + // Migrator now stamps `Date.now` onto pre-#214 archived 281 + // straggler entries via `@Dependency(\.date.now)`; pin a 282 + // fixed instant here so the migration path doesn't trip the 283 + // "live dependency accessed from test" guard. 284 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 285 + } operation: { 286 + @Shared(.appStorage("repositoryOrderIDs")) var legacyOrder: [String] = [] 287 + $legacyOrder.withLock { $0 = ["/tmp/repo-a"] } 288 + 289 + SidebarPersistenceMigrator.migrateIfNeeded( 290 + fileExists: { _ in true }, 291 + readFile: { try? storage.load($0) } 292 + ) 293 + 294 + // Migration actually ran: legacy got folded AND the file now 295 + // carries `schemaVersion == 1`. 296 + let data = try storage.load(SupacodePaths.sidebarURL) 297 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 298 + #expect(migrated.schemaVersion == 1) 299 + #expect(Array(migrated.sections.keys) == ["/tmp/repo-a"]) 300 + #expect(legacyOrder.isEmpty) 301 + } 302 + } 303 + 304 + @Test(.dependencies) func migratorSkipsWhenFileAlreadyHasSchemaVersion1() throws { 305 + let storage = InMemorySettingsFileStorage() 306 + // Pre-seed a migrated file with a recognizable section so we can 307 + // verify the bytes are untouched after a skipped migration. 308 + var seeded = SidebarState() 309 + seeded.schemaVersion = 1 310 + seeded.sections["/tmp/already-migrated"] = .init() 311 + let encoder = JSONEncoder() 312 + let seededBytes = try encoder.encode(seeded) 313 + try storage.save(seededBytes, SupacodePaths.sidebarURL) 314 + let suiteName = "\(#function).\(UUID().uuidString)" 315 + 316 + try withDependencies { 317 + $0.settingsFileStorage = SettingsFileStorage( 318 + load: { try storage.load($0) }, 319 + save: { try storage.save($0, $1) } 320 + ) 321 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 322 + // Migrator now stamps `Date.now` onto pre-#214 archived 323 + // straggler entries via `@Dependency(\.date.now)`; pin a 324 + // fixed instant here so the migration path doesn't trip the 325 + // "live dependency accessed from test" guard. 326 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 327 + } operation: { 328 + @Shared(.appStorage("repositoryOrderIDs")) var legacyOrder: [String] = [] 329 + $legacyOrder.withLock { $0 = ["/tmp/repo-a"] } 330 + 331 + SidebarPersistenceMigrator.migrateIfNeeded( 332 + fileExists: { _ in true }, 333 + readFile: { try? storage.load($0) } 334 + ) 335 + 336 + // File bytes untouched. 337 + #expect((try? storage.load(SupacodePaths.sidebarURL)) == seededBytes) 338 + // Legacy UserDefaults untouched since the migrator skipped. 339 + #expect(legacyOrder == ["/tmp/repo-a"]) 340 + } 341 + } 342 + 343 + @Test(.dependencies) func migratorFoldsSettingsFilePinnedWorktreeIDsIntoSidebar() throws { 344 + // Pins `@Shared(.settingsFile)` as a live hydration dependency: 345 + // if a future async-settings refactor lets this access return 346 + // before `pinnedWorktreeIDs` / `repositoryRoots` land, the 347 + // migrator would silently drop curation and this test would 348 + // catch it. 349 + let storage = InMemorySettingsFileStorage() 350 + let suiteName = "\(#function).\(UUID().uuidString)" 351 + 352 + try withDependencies { 353 + $0.settingsFileStorage = SettingsFileStorage( 354 + load: { try storage.load($0) }, 355 + save: { try storage.save($0, $1) } 356 + ) 357 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 358 + // Migrator now stamps `Date.now` onto pre-#214 archived 359 + // straggler entries via `@Dependency(\.date.now)`; pin a 360 + // fixed instant here so the migration path doesn't trip the 361 + // "live dependency accessed from test" guard. 362 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 363 + } operation: { 364 + @Shared(.settingsFile) var settings 365 + $settings.withLock { 366 + $0.repositoryRoots = ["/tmp/repo-a", "/tmp/repo-b"] 367 + $0.pinnedWorktreeIDs = ["/tmp/repo-a/feature", "/tmp/repo-b/bugfix"] 368 + } 369 + 370 + SidebarPersistenceMigrator.migrateIfNeeded( 371 + fileExists: { _ in false }, 372 + readFile: { try? storage.load($0) } 373 + ) 374 + 375 + let data = try storage.load(SupacodePaths.sidebarURL) 376 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 377 + 378 + #expect( 379 + migrated.sections["/tmp/repo-a"]?.buckets[.pinned]?.items["/tmp/repo-a/feature"] != nil 380 + ) 381 + #expect( 382 + migrated.sections["/tmp/repo-b"]?.buckets[.pinned]?.items["/tmp/repo-b/bugfix"] != nil 383 + ) 384 + // Legacy pinned list on the settings file was cleared once the 385 + // bucketed form took ownership. 386 + #expect(settings.pinnedWorktreeIDs.isEmpty) 387 + } 388 + } 389 + 390 + @Test(.dependencies) func migratorFoldsPre214ArchivedWorktreeIDsWithInjectedNow() throws { 391 + // T1 — pre-#214 archive migration. The retired 392 + // `ArchivedWorktreeDatesClient.liveValue.load` used to fold a 393 + // bare `[String]` ID list into `archivedWorktreeDates` on 394 + // first read; the migrator now inherits that job. Seed ONLY 395 + // the legacy ID list (no dated dictionary, no row order) plus 396 + // the owning root so the rescue pass can place the entries, 397 + // then assert the migrated `sidebar.json` has both worktrees 398 + // in `.archived` stamped with the injected `date.now`, and 399 + // the legacy ID list cleared post-write. 400 + let storage = InMemorySettingsFileStorage() 401 + let suiteName = "\(#function).\(UUID().uuidString)" 402 + let injectedNow = Date(timeIntervalSince1970: 1_700_000_000) 403 + 404 + try withDependencies { 405 + $0.settingsFileStorage = SettingsFileStorage( 406 + load: { try storage.load($0) }, 407 + save: { try storage.save($0, $1) } 408 + ) 409 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 410 + $0.date.now = injectedNow 411 + } operation: { 412 + @Shared(.appStorage("archivedWorktreeIDs")) var legacyArchivedIDs: [String] = [] 413 + @Shared(.appStorage("archivedWorktreeDates")) var legacyArchived: [String: Date] = [:] 414 + @Shared(.settingsFile) var settings 415 + $settings.withLock { 416 + $0.repositoryRoots = ["/tmp/repo-a"] 417 + } 418 + $legacyArchivedIDs.withLock { 419 + $0 = ["/tmp/repo-a/wt-1", "/tmp/repo-a/wt-2"] 420 + } 421 + 422 + SidebarPersistenceMigrator.migrateIfNeeded( 423 + fileExists: { _ in false }, 424 + readFile: { try? storage.load($0) } 425 + ) 426 + 427 + let data = try storage.load(SupacodePaths.sidebarURL) 428 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 429 + let archived = migrated.sections["/tmp/repo-a"]?.buckets[.archived]?.items 430 + #expect(archived?["/tmp/repo-a/wt-1"]?.archivedAt == injectedNow) 431 + #expect(archived?["/tmp/repo-a/wt-2"]?.archivedAt == injectedNow) 432 + // Legacy ID list cleared post-write — the migrator is the 433 + // last reader of that key and must drop it once the 434 + // timestamps land in the bucketed archive. 435 + #expect(legacyArchivedIDs.isEmpty) 436 + // Dated dictionary was empty on input and should stay empty 437 + // after the fold (the migrator folds straight into the 438 + // bucketed `.archived` collection, not back into the legacy 439 + // dated dictionary). 440 + #expect(legacyArchived.isEmpty) 441 + } 442 + } 443 + 444 + @Test(.dependencies) func migratorSeedsBaselineSectionOrderFromRepositoryRoots() throws { 445 + // T2 — baseline order from `repositoryRoots`. When the user 446 + // never curated a row order (`repositoryOrderIDs` empty, 447 + // `worktreeOrderByRepository` empty), `sidebar.json` must 448 + // still materialise one empty section per known root, in 449 + // settings order, so repos with only a main worktree stay 450 + // visible after migration. 451 + let storage = InMemorySettingsFileStorage() 452 + let suiteName = "\(#function).\(UUID().uuidString)" 453 + 454 + try withDependencies { 455 + $0.settingsFileStorage = SettingsFileStorage( 456 + load: { try storage.load($0) }, 457 + save: { try storage.save($0, $1) } 458 + ) 459 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 460 + // Migrator now stamps `Date.now` onto pre-#214 archived 461 + // straggler entries via `@Dependency(\.date.now)`; pin a 462 + // fixed instant here so the migration path doesn't trip the 463 + // "live dependency accessed from test" guard. 464 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 465 + } operation: { 466 + @Shared(.settingsFile) var settings 467 + $settings.withLock { 468 + $0.repositoryRoots = ["/tmp/repo-a", "/tmp/repo-b", "/tmp/repo-c"] 469 + } 470 + 471 + SidebarPersistenceMigrator.migrateIfNeeded( 472 + fileExists: { _ in false }, 473 + readFile: { try? storage.load($0) } 474 + ) 475 + 476 + let data = try storage.load(SupacodePaths.sidebarURL) 477 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 478 + #expect(Array(migrated.sections.keys) == ["/tmp/repo-a", "/tmp/repo-b", "/tmp/repo-c"]) 479 + for (_, section) in migrated.sections { 480 + // Every seeded section is empty: no curated buckets, 481 + // collapse bit defaulted, ready for the reducer's seed 482 + // pass to fill in `.unpinned` once worktrees hydrate. 483 + #expect(section.buckets.isEmpty) 484 + #expect(section.collapsed == false) 485 + } 486 + } 487 + } 488 + 489 + @Test(.dependencies) func migratorLegacyOrderMovesMatchingRootsToTopAndAppendsRest() throws { 490 + // T3 — `legacyOrder` override layer. Baseline is 491 + // `repositoryRoots` in settings order; `repositoryOrderIDs` 492 + // applies as a move-to-top override so repos the user 493 + // explicitly curated win. Roots present in settings but 494 + // missing from `legacyOrder` append in settings order after 495 + // the curated prefix; duplicates in `legacyOrder` stay unique. 496 + let storage = InMemorySettingsFileStorage() 497 + let suiteName = "\(#function).\(UUID().uuidString)" 498 + 499 + try withDependencies { 500 + $0.settingsFileStorage = SettingsFileStorage( 501 + load: { try storage.load($0) }, 502 + save: { try storage.save($0, $1) } 503 + ) 504 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 505 + // Migrator now stamps `Date.now` onto pre-#214 archived 506 + // straggler entries via `@Dependency(\.date.now)`; pin a 507 + // fixed instant here so the migration path doesn't trip the 508 + // "live dependency accessed from test" guard. 509 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 510 + } operation: { 511 + @Shared(.settingsFile) var settings 512 + @Shared(.appStorage("repositoryOrderIDs")) var legacyOrder: [String] = [] 513 + $settings.withLock { 514 + $0.repositoryRoots = ["/tmp/repo-a", "/tmp/repo-b", "/tmp/repo-c", "/tmp/repo-d"] 515 + } 516 + $legacyOrder.withLock { 517 + $0 = ["/tmp/repo-c", "/tmp/repo-a"] 518 + } 519 + 520 + SidebarPersistenceMigrator.migrateIfNeeded( 521 + fileExists: { _ in false }, 522 + readFile: { try? storage.load($0) } 523 + ) 524 + 525 + let data = try storage.load(SupacodePaths.sidebarURL) 526 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 527 + #expect( 528 + Array(migrated.sections.keys) == [ 529 + "/tmp/repo-c", 530 + "/tmp/repo-a", 531 + "/tmp/repo-b", 532 + "/tmp/repo-d", 533 + ] 534 + ) 535 + } 536 + } 537 + 538 + @Test(.dependencies) func migratorNormalisesNonCanonicalPathsIntoCanonicalSectionKeys() throws { 539 + // T4 — non-canonical path normalisation. Seed 540 + // `repositoryRoots` and `pinnedWorktreeIDs` with paths that 541 + // differ from their canonical form (redundant `.` components 542 + // and a trailing slash — the two shapes this branch's 543 + // `RepositoryPathNormalizer.normalize(_:)` is known to 544 + // canonicalise). Assert every section key + pinned key in 545 + // the migrated `sidebar.json` matches 546 + // `RepositoryPathNormalizer.normalize(_:)` output, so 547 + // downstream `Repository.ID` string comparisons line up 548 + // regardless of which shape the user originally typed. 549 + let storage = InMemorySettingsFileStorage() 550 + let suiteName = "\(#function).\(UUID().uuidString)" 551 + let nonCanonicalRoot = "/tmp/./repo-a" 552 + let nonCanonicalWorktree = "/tmp/./repo-a/feature" 553 + let canonicalRoot = RepositoryPathNormalizer.normalize(nonCanonicalRoot)! 554 + let canonicalWorktree = RepositoryPathNormalizer.normalize(nonCanonicalWorktree)! 555 + // Sanity-check the fixture: the raw inputs MUST differ from 556 + // their canonical shapes, otherwise the test degenerates into 557 + // a tautology that would quietly pass if the normaliser 558 + // regressed to `identity`. 559 + #expect(nonCanonicalRoot != canonicalRoot) 560 + #expect(nonCanonicalWorktree != canonicalWorktree) 561 + 562 + try withDependencies { 563 + $0.settingsFileStorage = SettingsFileStorage( 564 + load: { try storage.load($0) }, 565 + save: { try storage.save($0, $1) } 566 + ) 567 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 568 + // Migrator now stamps `Date.now` onto pre-#214 archived 569 + // straggler entries via `@Dependency(\.date.now)`; pin a 570 + // fixed instant here so the migration path doesn't trip the 571 + // "live dependency accessed from test" guard. 572 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 573 + } operation: { 574 + @Shared(.settingsFile) var settings 575 + // Bypass `RepositoryRootsKey` / `PinnedWorktreeIDsKey`'s 576 + // `save(...)` path (which normalises on write) by mutating 577 + // the backing `.settingsFile` directly — the migrator must 578 + // be robust against already-persisted non-canonical bytes 579 + // sitting on disk from an older build. 580 + $settings.withLock { 581 + $0.repositoryRoots = [nonCanonicalRoot] 582 + $0.pinnedWorktreeIDs = [nonCanonicalWorktree] 583 + } 584 + 585 + SidebarPersistenceMigrator.migrateIfNeeded( 586 + fileExists: { _ in false }, 587 + readFile: { try? storage.load($0) } 588 + ) 589 + 590 + let data = try storage.load(SupacodePaths.sidebarURL) 591 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 592 + // Section keyed on the canonical repo ID, and the pinned 593 + // worktree lives in that section's `.pinned` bucket under 594 + // its own canonical key. 595 + #expect(Array(migrated.sections.keys) == [canonicalRoot]) 596 + let pinnedItems = migrated.sections[canonicalRoot]?.buckets[.pinned]?.items 597 + #expect(pinnedItems?[canonicalWorktree] != nil) 598 + // Non-canonical spellings must NOT leak into the migrated 599 + // state — otherwise string comparisons against live 600 + // `Repository.ID` / `Worktree.ID` would drift depending on 601 + // which code path inserted the row. 602 + #expect(migrated.sections[nonCanonicalRoot] == nil) 603 + #expect(pinnedItems?[nonCanonicalWorktree] == nil) 604 + } 605 + } 606 + 607 + @Test(.dependencies) func rescuesOrphanPinnedViaDefaultWorktreeBaseConvention() throws { 608 + // T6 — default `~/.supacode/repos/<name>/` convention. The 609 + // legacy pinned path lives under the convention base, which 610 + // shares no common ancestor with the repo root stored in 611 + // `repositoryRoots`. The new `rootCandidates(...)` helper must 612 + // emit the convention base as a candidate paired with the 613 + // owning root so prefix-matching places the pin correctly. 614 + let storage = InMemorySettingsFileStorage() 615 + let suiteName = "\(#function).\(UUID().uuidString)" 616 + let rootURL = URL(fileURLWithPath: "/Developer/X/foo", isDirectory: true) 617 + let owningRootID = RepositoryPathNormalizer.normalize( 618 + rootURL.path(percentEncoded: false) 619 + )! 620 + // Pin lives under the default convention base — derive the 621 + // exact path from `SupacodePaths` so the expectation matches 622 + // whatever `worktreeBaseDirectory(...)` produces at runtime. 623 + let conventionBase = SupacodePaths.worktreeBaseDirectory( 624 + for: rootURL, 625 + globalDefaultPath: nil, 626 + repositoryOverridePath: nil 627 + ) 628 + let pinnedPath = 629 + conventionBase 630 + .appending(path: "sbertix", directoryHint: .isDirectory) 631 + .appending(path: "branch-a", directoryHint: .isDirectory) 632 + .path(percentEncoded: false) 633 + let canonicalPinnedID = RepositoryPathNormalizer.normalize(pinnedPath)! 634 + 635 + try withDependencies { 636 + $0.settingsFileStorage = SettingsFileStorage( 637 + load: { try storage.load($0) }, 638 + save: { try storage.save($0, $1) } 639 + ) 640 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 641 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 642 + } operation: { 643 + @Shared(.settingsFile) var settings 644 + $settings.withLock { 645 + $0.repositoryRoots = [rootURL.path(percentEncoded: false)] 646 + $0.pinnedWorktreeIDs = [pinnedPath] 647 + } 648 + 649 + SidebarPersistenceMigrator.migrateIfNeeded( 650 + fileExists: { _ in false }, 651 + readFile: { try? storage.load($0) } 652 + ) 653 + 654 + let data = try storage.load(SupacodePaths.sidebarURL) 655 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 656 + #expect( 657 + migrated.sections[owningRootID]?.buckets[.pinned]?.items[canonicalPinnedID] != nil 658 + ) 659 + } 660 + } 661 + 662 + @Test(.dependencies) func rescuesOrphanPinnedViaGlobalWorktreeBaseOverride() throws { 663 + // T7 — global `defaultWorktreeBaseDirectoryPath` override. The 664 + // effective base is `<globalBase>/<root.lastPathComponent>/` 665 + // and the pin sits directly under it; the candidate pool must 666 + // include the global-override base paired with the owning 667 + // root. 668 + let storage = InMemorySettingsFileStorage() 669 + let suiteName = "\(#function).\(UUID().uuidString)" 670 + let rootURL = URL(fileURLWithPath: "/Developer/X/foo", isDirectory: true) 671 + let owningRootID = RepositoryPathNormalizer.normalize( 672 + rootURL.path(percentEncoded: false) 673 + )! 674 + let globalBase = "/tmp/shared-worktrees" 675 + let overrideBase = SupacodePaths.worktreeBaseDirectory( 676 + for: rootURL, 677 + globalDefaultPath: globalBase, 678 + repositoryOverridePath: nil 679 + ) 680 + let pinnedPath = 681 + overrideBase 682 + .appending(path: "branch-a", directoryHint: .isDirectory) 683 + .path(percentEncoded: false) 684 + let canonicalPinnedID = RepositoryPathNormalizer.normalize(pinnedPath)! 685 + 686 + try withDependencies { 687 + $0.settingsFileStorage = SettingsFileStorage( 688 + load: { try storage.load($0) }, 689 + save: { try storage.save($0, $1) } 690 + ) 691 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 692 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 693 + } operation: { 694 + @Shared(.settingsFile) var settings 695 + $settings.withLock { 696 + $0.global.defaultWorktreeBaseDirectoryPath = globalBase 697 + $0.repositoryRoots = [rootURL.path(percentEncoded: false)] 698 + $0.pinnedWorktreeIDs = [pinnedPath] 699 + } 700 + 701 + SidebarPersistenceMigrator.migrateIfNeeded( 702 + fileExists: { _ in false }, 703 + readFile: { try? storage.load($0) } 704 + ) 705 + 706 + let data = try storage.load(SupacodePaths.sidebarURL) 707 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 708 + #expect( 709 + migrated.sections[owningRootID]?.buckets[.pinned]?.items[canonicalPinnedID] != nil 710 + ) 711 + } 712 + } 713 + 714 + @Test(.dependencies) func rescuesOrphanPinnedViaPerRepoWorktreeBaseOverride() throws { 715 + // T8 — per-repo `supacode.json` override. The migrator reads 716 + // the override synchronously via 717 + // `\.repositoryLocalSettingsStorage` (NOT the async 718 + // `@Shared(.repositorySettings(rootURL))` key) so the 719 + // migrator doesn't race the SharedKey hydration pipeline. 720 + let storage = InMemorySettingsFileStorage() 721 + let localSettingsStorage = RepositoryLocalSettingsTestStorage() 722 + let suiteName = "\(#function).\(UUID().uuidString)" 723 + let rootURL = URL(fileURLWithPath: "/Developer/X/foo", isDirectory: true) 724 + let owningRootID = RepositoryPathNormalizer.normalize( 725 + rootURL.path(percentEncoded: false) 726 + )! 727 + let overrideBase = "/Volumes/External/worktrees" 728 + let pinnedPath = URL(fileURLWithPath: overrideBase, isDirectory: true) 729 + .appending(path: "branch-a", directoryHint: .isDirectory) 730 + .path(percentEncoded: false) 731 + let canonicalPinnedID = RepositoryPathNormalizer.normalize(pinnedPath)! 732 + 733 + // Seed the per-repo `supacode.json` via the injected storage 734 + // so the migrator's synchronous load succeeds. 735 + var perRepoSettings = RepositorySettings.default 736 + perRepoSettings.worktreeBaseDirectoryPath = overrideBase 737 + let encoder = JSONEncoder() 738 + try localSettingsStorage.save( 739 + encoder.encode(perRepoSettings), 740 + at: SupacodePaths.repositorySettingsURL(for: rootURL) 741 + ) 742 + 743 + try withDependencies { 744 + $0.settingsFileStorage = SettingsFileStorage( 745 + load: { try storage.load($0) }, 746 + save: { try storage.save($0, $1) } 747 + ) 748 + $0.repositoryLocalSettingsStorage = localSettingsStorage.storage 749 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 750 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 751 + } operation: { 752 + @Shared(.settingsFile) var settings 753 + $settings.withLock { 754 + $0.repositoryRoots = [rootURL.path(percentEncoded: false)] 755 + $0.pinnedWorktreeIDs = [pinnedPath] 756 + } 757 + 758 + SidebarPersistenceMigrator.migrateIfNeeded( 759 + fileExists: { _ in false }, 760 + readFile: { try? storage.load($0) } 761 + ) 762 + 763 + let data = try storage.load(SupacodePaths.sidebarURL) 764 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 765 + #expect( 766 + migrated.sections[owningRootID]?.buckets[.pinned]?.items[canonicalPinnedID] != nil 767 + ) 768 + } 769 + } 770 + 771 + @Test(.dependencies) func rescuesOrphanArchivedViaDefaultWorktreeBaseConvention() throws { 772 + // T9 — archived resolver via default convention. Mirrors T6 773 + // but for the archived-worktree fold: an entry sitting under 774 + // `~/.supacode/repos/<name>/` with a timestamp should land in 775 + // `.archived` on the settings root it belongs to. 776 + let storage = InMemorySettingsFileStorage() 777 + let suiteName = "\(#function).\(UUID().uuidString)" 778 + let rootURL = URL(fileURLWithPath: "/Developer/X/foo", isDirectory: true) 779 + let owningRootID = RepositoryPathNormalizer.normalize( 780 + rootURL.path(percentEncoded: false) 781 + )! 782 + let conventionBase = SupacodePaths.worktreeBaseDirectory( 783 + for: rootURL, 784 + globalDefaultPath: nil, 785 + repositoryOverridePath: nil 786 + ) 787 + let archivedPath = 788 + conventionBase 789 + .appending(path: "sbertix", directoryHint: .isDirectory) 790 + .appending(path: "branch-a", directoryHint: .isDirectory) 791 + .path(percentEncoded: false) 792 + let canonicalArchivedID = RepositoryPathNormalizer.normalize(archivedPath)! 793 + let archivedAt = Date(timeIntervalSince1970: 1_000_000) 794 + 795 + try withDependencies { 796 + $0.settingsFileStorage = SettingsFileStorage( 797 + load: { try storage.load($0) }, 798 + save: { try storage.save($0, $1) } 799 + ) 800 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 801 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 802 + } operation: { 803 + @Shared(.settingsFile) var settings 804 + @Shared(.appStorage("archivedWorktreeDates")) var legacyArchived: [String: Date] = [:] 805 + $settings.withLock { 806 + $0.repositoryRoots = [rootURL.path(percentEncoded: false)] 807 + } 808 + $legacyArchived.withLock { 809 + $0 = [archivedPath: archivedAt] 810 + } 811 + 812 + SidebarPersistenceMigrator.migrateIfNeeded( 813 + fileExists: { _ in false }, 814 + readFile: { try? storage.load($0) } 815 + ) 816 + 817 + let data = try storage.load(SupacodePaths.sidebarURL) 818 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 819 + let archived = migrated.sections[owningRootID]?.buckets[.archived]?.items 820 + #expect(archived?[canonicalArchivedID]?.archivedAt == archivedAt) 821 + } 822 + } 823 + 824 + @Test(.dependencies) func writesEmptySidebarOnFreshInstall() throws { 825 + let storage = InMemorySettingsFileStorage() 826 + let suiteName = "\(#function).\(UUID().uuidString)" 827 + 828 + try withDependencies { 829 + $0.settingsFileStorage = SettingsFileStorage( 830 + load: { try storage.load($0) }, 831 + save: { try storage.save($0, $1) } 832 + ) 833 + $0.defaultAppStorage = UserDefaults(suiteName: suiteName)! 834 + // Migrator now stamps `Date.now` onto pre-#214 archived 835 + // straggler entries via `@Dependency(\.date.now)`; pin a 836 + // fixed instant here so the migration path doesn't trip the 837 + // "live dependency accessed from test" guard. 838 + $0.date = .constant(Date(timeIntervalSince1970: 1_700_000_000)) 839 + } operation: { 840 + SidebarPersistenceMigrator.migrateIfNeeded(fileExists: { _ in false }) 841 + 842 + let data = try storage.load(SupacodePaths.sidebarURL) 843 + let migrated = try JSONDecoder().decode(SidebarState.self, from: data) 844 + #expect(migrated.sections.isEmpty) 845 + #expect(migrated.focusedWorktreeID == nil) 846 + } 847 + } 848 + }
+312
supacodeTests/SidebarStateTests.swift
··· 1 + import Foundation 2 + import OrderedCollections 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + @MainActor 8 + struct SidebarStateTests { 9 + private let repoA = "/tmp/repo-a" 10 + private let repoB = "/tmp/repo-b" 11 + 12 + // MARK: - move 13 + 14 + @Test func movePreservesItemPayloadAcrossBuckets() { 15 + var state = SidebarState() 16 + state.insert( 17 + worktree: "wt-1", 18 + in: repoA, 19 + bucket: .unpinned, 20 + item: .init(archivedAt: nil) 21 + ) 22 + 23 + state.move(worktree: "wt-1", in: repoA, from: .unpinned, to: .pinned, position: 0) 24 + 25 + #expect(state.sections[repoA]?.buckets[.unpinned]?.items.isEmpty == true) 26 + #expect(state.sections[repoA]?.buckets[.pinned]?.items["wt-1"] != nil) 27 + } 28 + 29 + @Test func moveClearsArchivedAtWhenLeavingArchived() { 30 + var state = SidebarState() 31 + state.insert( 32 + worktree: "wt-1", 33 + in: repoA, 34 + bucket: .archived, 35 + item: .init(archivedAt: Date(timeIntervalSince1970: 1_000_000)) 36 + ) 37 + 38 + state.move(worktree: "wt-1", in: repoA, from: .archived, to: .unpinned, position: 0) 39 + 40 + #expect(state.sections[repoA]?.buckets[.archived]?.items["wt-1"] == nil) 41 + #expect(state.sections[repoA]?.buckets[.unpinned]?.items["wt-1"]?.archivedAt == nil) 42 + } 43 + 44 + @Test func moveNoopWhenItemNotInSourceBucket() { 45 + var state = SidebarState() 46 + state.insert(worktree: "wt-1", in: repoA, bucket: .unpinned) 47 + 48 + // Source bucket is `.pinned`, but the item lives in `.unpinned`. 49 + state.move(worktree: "wt-1", in: repoA, from: .pinned, to: .archived, position: 0) 50 + 51 + #expect(state.sections[repoA]?.buckets[.unpinned]?.items["wt-1"] != nil) 52 + #expect(state.sections[repoA]?.buckets[.archived] == nil) 53 + } 54 + 55 + @Test func moveToSameBucketReordersToPosition() { 56 + var state = SidebarState() 57 + state.insert(worktree: "wt-1", in: repoA, bucket: .unpinned) 58 + state.insert(worktree: "wt-2", in: repoA, bucket: .unpinned) 59 + state.insert(worktree: "wt-3", in: repoA, bucket: .unpinned) 60 + 61 + // Bump wt-3 to the top of `.unpinned`. 62 + state.move(worktree: "wt-3", in: repoA, from: .unpinned, to: .unpinned, position: 0) 63 + 64 + let order = Array(state.sections[repoA]?.buckets[.unpinned]?.items.keys ?? []) 65 + #expect(order == ["wt-3", "wt-1", "wt-2"]) 66 + } 67 + 68 + // MARK: - archive / unarchive 69 + 70 + @Test func archivePlacesWorktreeIntoArchivedBucketWithTimestamp() { 71 + var state = SidebarState() 72 + state.insert(worktree: "wt-1", in: repoA, bucket: .unpinned) 73 + let timestamp = Date(timeIntervalSince1970: 1_000_000) 74 + 75 + state.archive(worktree: "wt-1", in: repoA, from: .unpinned, at: timestamp) 76 + 77 + #expect(state.sections[repoA]?.buckets[.unpinned]?.items.isEmpty == true) 78 + #expect(state.sections[repoA]?.buckets[.archived]?.items["wt-1"]?.archivedAt == timestamp) 79 + } 80 + 81 + @Test func unarchiveRestoresToTopOfUnpinnedAndClearsTimestamp() { 82 + var state = SidebarState() 83 + state.insert( 84 + worktree: "wt-archived", 85 + in: repoA, 86 + bucket: .archived, 87 + item: .init(archivedAt: Date(timeIntervalSince1970: 1_000_000)) 88 + ) 89 + state.insert(worktree: "wt-live", in: repoA, bucket: .unpinned) 90 + 91 + state.unarchive(worktree: "wt-archived", in: repoA) 92 + 93 + #expect(state.sections[repoA]?.buckets[.archived]?.items.isEmpty == true) 94 + let unpinnedOrder = Array(state.sections[repoA]?.buckets[.unpinned]?.items.keys ?? []) 95 + #expect(unpinnedOrder == ["wt-archived", "wt-live"]) 96 + #expect(state.sections[repoA]?.buckets[.unpinned]?.items["wt-archived"]?.archivedAt == nil) 97 + } 98 + 99 + // MARK: - remove 100 + 101 + @Test func removeDropsFromGivenBucketOnly() { 102 + var state = SidebarState() 103 + state.insert(worktree: "wt-1", in: repoA, bucket: .pinned) 104 + state.insert(worktree: "wt-1", in: repoA, bucket: .unpinned) 105 + 106 + state.remove(worktree: "wt-1", in: repoA, from: .pinned) 107 + 108 + #expect(state.sections[repoA]?.buckets[.pinned]?.items["wt-1"] == nil) 109 + #expect(state.sections[repoA]?.buckets[.unpinned]?.items["wt-1"] != nil) 110 + } 111 + 112 + // MARK: - reorder 113 + 114 + @Test func reorderPreservesPayloadsAndBucketScope() { 115 + var state = SidebarState() 116 + state.insert(worktree: "p-1", in: repoA, bucket: .pinned) 117 + state.insert(worktree: "p-2", in: repoA, bucket: .pinned) 118 + state.insert(worktree: "u-1", in: repoA, bucket: .unpinned) 119 + 120 + state.reorder(bucket: .pinned, in: repoA, to: ["p-2", "p-1"]) 121 + 122 + let pinned = Array(state.sections[repoA]?.buckets[.pinned]?.items.keys ?? []) 123 + let unpinned = Array(state.sections[repoA]?.buckets[.unpinned]?.items.keys ?? []) 124 + #expect(pinned == ["p-2", "p-1"]) 125 + #expect(unpinned == ["u-1"]) 126 + } 127 + 128 + @Test func reorderPartiallyOverlappingInputPreservesOtherItemsInOriginalOrder() { 129 + // Pins the contract of `reorder(bucket:in:to:)` when the 130 + // `reorderedIDs` list only partially overlaps the bucket: 131 + // - IDs in `reorderedIDs` that aren't currently in the bucket 132 + // (here `X`) are silently dropped. 133 + // - Items in the bucket that aren't in `reorderedIDs` (here 134 + // `B` and `D`) keep their relative order and are spliced 135 + // after the reordered run — matching the position of the 136 + // first reordered item in the original order. 137 + var state = SidebarState() 138 + state.insert(worktree: "A", in: repoA, bucket: .unpinned) 139 + state.insert(worktree: "B", in: repoA, bucket: .unpinned) 140 + state.insert(worktree: "C", in: repoA, bucket: .unpinned) 141 + state.insert(worktree: "D", in: repoA, bucket: .unpinned) 142 + 143 + state.reorder(bucket: .unpinned, in: repoA, to: ["C", "X", "A"]) 144 + 145 + let order = Array(state.sections[repoA]?.buckets[.unpinned]?.items.keys ?? []) 146 + #expect(order == ["C", "A", "B", "D"]) 147 + } 148 + 149 + // MARK: - archivedWorktrees accessor 150 + 151 + @Test func archivedWorktreesEnumeratesAcrossSections() { 152 + var state = SidebarState() 153 + let earlierDate = Date(timeIntervalSince1970: 1_000_000) 154 + let laterDate = Date(timeIntervalSince1970: 2_000_000) 155 + state.insert( 156 + worktree: "wt-a", in: repoA, bucket: .archived, item: .init(archivedAt: earlierDate) 157 + ) 158 + state.insert( 159 + worktree: "wt-b", in: repoB, bucket: .archived, item: .init(archivedAt: laterDate) 160 + ) 161 + 162 + let archived = state.archivedWorktrees 163 + 164 + #expect(archived.count == 2) 165 + #expect(archived.contains { $0.worktreeID == "wt-a" && $0.archivedAt == earlierDate }) 166 + #expect(archived.contains { $0.worktreeID == "wt-b" && $0.archivedAt == laterDate }) 167 + } 168 + 169 + // MARK: - Codable round-trip 170 + 171 + @Test func codableRoundTripPreservesNestedShape() throws { 172 + var original = SidebarState() 173 + original.focusedWorktreeID = "wt-focus" 174 + original.sections[repoA] = .init(collapsed: true) 175 + original.insert(worktree: "p-1", in: repoA, bucket: .pinned) 176 + original.insert(worktree: "u-1", in: repoA, bucket: .unpinned) 177 + original.insert( 178 + worktree: "a-1", 179 + in: repoA, 180 + bucket: .archived, 181 + item: .init(archivedAt: Date(timeIntervalSince1970: 1_000_000)) 182 + ) 183 + original.insert(worktree: "b-u-1", in: repoB, bucket: .unpinned) 184 + 185 + let encoder = JSONEncoder() 186 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 187 + let data = try encoder.encode(original) 188 + let decoded = try JSONDecoder().decode(SidebarState.self, from: data) 189 + 190 + #expect(decoded == original) 191 + } 192 + 193 + @Test func codableEmptyRoundTrip() throws { 194 + let original = SidebarState() 195 + let data = try JSONEncoder().encode(original) 196 + let decoded = try JSONDecoder().decode(SidebarState.self, from: data) 197 + #expect(decoded == original) 198 + } 199 + 200 + @Test func onDiskBucketKeysAreStableWireFormat() throws { 201 + // Pins the literal bucket-id / item-field strings that 202 + // `sidebar.json` uses on disk. Renaming an enum case or a 203 + // Codable field without flipping the `rawValue` would silently 204 + // diverge the schema and break the migrator's idempotency, 205 + // since the file-existence gate would latch the new shape as 206 + // "already migrated" on every future launch. 207 + var state = SidebarState() 208 + state.sections[repoA] = .init(collapsed: true) 209 + state.insert(worktree: "wt-1", in: repoA, bucket: .pinned) 210 + state.insert(worktree: "wt-2", in: repoA, bucket: .unpinned) 211 + state.insert( 212 + worktree: "wt-3", 213 + in: repoA, 214 + bucket: .archived, 215 + item: .init(archivedAt: Date(timeIntervalSince1970: 1_000_000)) 216 + ) 217 + let encoder = JSONEncoder() 218 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 219 + let data = try encoder.encode(state) 220 + let json = try #require(String(data: data, encoding: .utf8)) 221 + #expect(json.contains("\"pinned\"")) 222 + #expect(json.contains("\"unpinned\"")) 223 + #expect(json.contains("\"archived\"")) 224 + #expect(json.contains("\"archivedAt\"")) 225 + #expect(json.contains("\"collapsed\"")) 226 + #expect(json.contains("\"buckets\"")) 227 + #expect(json.contains("\"schemaVersion\"")) 228 + } 229 + 230 + @Test func emptyStateWireFormatAlwaysEncodesSchemaVersionAndSectionDefaults() throws { 231 + // Exhaustive pin: a default-constructed `SidebarState` still 232 + // emits `schemaVersion` (always present so the migrator can 233 + // round-trip the value), and a freshly-materialised `Section` 234 + // always emits both `collapsed` and `buckets` — never 235 + // defaulted-field-omitted — so the wire format stays stable 236 + // for the migrator's idempotency contract. 237 + var state = SidebarState() 238 + // Default section: `collapsed == false`, empty buckets. The 239 + // previous encoder skipped both fields in this case; the new 240 + // encoder must emit them. 241 + state.sections[repoA] = .init() 242 + let encoder = JSONEncoder() 243 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 244 + let data = try encoder.encode(state) 245 + let json = try #require(String(data: data, encoding: .utf8)) 246 + #expect(json.contains("\"schemaVersion\"")) 247 + #expect(json.contains("\"collapsed\"")) 248 + #expect(json.contains("\"buckets\"")) 249 + } 250 + 251 + @Test func unarchiveRepeatedlyDoesNotLeakBuckets() { 252 + // Regression pin: calling `unarchive` on a worktree that was 253 + // never archived must be a no-op — the seed pass relies on 254 + // this invariant when it materialises default `.unpinned` 255 + // entries. 256 + var state = SidebarState() 257 + state.insert(worktree: "wt-1", in: repoA, bucket: .unpinned) 258 + 259 + state.unarchive(worktree: "wt-1", in: repoA) 260 + state.unarchive(worktree: "nonexistent", in: repoA) 261 + 262 + #expect(state.sections[repoA]?.buckets[.unpinned]?.items["wt-1"] != nil) 263 + #expect(state.sections[repoA]?.buckets[.archived] == nil) 264 + } 265 + 266 + @Test func currentBucketReportsMembershipAcrossBuckets() { 267 + var state = SidebarState() 268 + state.insert(worktree: "p", in: repoA, bucket: .pinned) 269 + state.insert(worktree: "u", in: repoA, bucket: .unpinned) 270 + state.insert( 271 + worktree: "a", 272 + in: repoA, 273 + bucket: .archived, 274 + item: .init(archivedAt: Date(timeIntervalSince1970: 1)) 275 + ) 276 + 277 + #expect(state.currentBucket(of: "p", in: repoA) == .pinned) 278 + #expect(state.currentBucket(of: "u", in: repoA) == .unpinned) 279 + #expect(state.currentBucket(of: "a", in: repoA) == .archived) 280 + #expect(state.currentBucket(of: "missing", in: repoA) == nil) 281 + #expect(state.currentBucket(of: "p", in: repoB) == nil) 282 + } 283 + 284 + @Test func removeAnywhereClearsEveryBucket() { 285 + var state = SidebarState() 286 + state.insert(worktree: "wt", in: repoA, bucket: .pinned) 287 + state.insert(worktree: "wt", in: repoA, bucket: .unpinned) 288 + state.insert( 289 + worktree: "wt", 290 + in: repoA, 291 + bucket: .archived, 292 + item: .init(archivedAt: Date(timeIntervalSince1970: 1)) 293 + ) 294 + 295 + state.removeAnywhere(worktree: "wt", in: repoA) 296 + 297 + #expect(state.sections[repoA]?.buckets[.pinned]?.items["wt"] == nil) 298 + #expect(state.sections[repoA]?.buckets[.unpinned]?.items["wt"] == nil) 299 + #expect(state.sections[repoA]?.buckets[.archived]?.items["wt"] == nil) 300 + } 301 + 302 + @Test func sectionCollapsedDefaultsToFalseWhenKeyIsAbsent() throws { 303 + // Legacy `sidebar.json` written before `collapsed` became 304 + // non-optional had no key for the default case. Decoding a 305 + // Section without the `collapsed` field must fall back to 306 + // `false` so those files still load cleanly. 307 + let json = Data("{}".utf8) 308 + let decoded = try JSONDecoder().decode(SidebarState.Section.self, from: json) 309 + #expect(decoded.collapsed == false) 310 + #expect(decoded.buckets.isEmpty) 311 + } 312 + }
+23 -3
supacodeTests/ToolbarNotificationGroupingTests.swift
··· 1 1 import Foundation 2 2 import IdentifiedCollections 3 + import OrderedCollections 4 + import Sharing 3 5 import Testing 4 6 5 7 @testable import supacode ··· 22 24 23 25 var state = RepositoriesFeature.State(repositories: [repoA, repoB]) 24 26 state.repositoryRoots = [repoA.rootURL, repoB.rootURL] 25 - state.repositoryOrderIDs = [repoB.id, repoA.id] 26 - state.worktreeOrderByRepository[repoA.id] = [repoATwo.id, repoAOne.id] 27 + state.$sidebar.withLock { sidebar in 28 + sidebar.sections[repoB.id] = .init() 29 + sidebar.sections[repoA.id] = .init( 30 + buckets: [ 31 + .unpinned: .init( 32 + items: [ 33 + repoATwo.id: .init(), 34 + repoAOne.id: .init(), 35 + ] 36 + ) 37 + ] 38 + ) 39 + } 27 40 28 41 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 29 42 manager.state(for: repoAOne).notifications = [ ··· 58 71 59 72 var state = RepositoriesFeature.State(repositories: [repoA, repoB]) 60 73 state.repositoryRoots = [repoA.rootURL, repoB.rootURL] 61 - state.archivedWorktreeDates[repoAArchived.id] = Date(timeIntervalSince1970: 1_000_000) 74 + state.$sidebar.withLock { sidebar in 75 + sidebar.insert( 76 + worktree: repoAArchived.id, 77 + in: repoA.id, 78 + bucket: .archived, 79 + item: .init(archivedAt: Date(timeIntervalSince1970: 1_000_000)) 80 + ) 81 + } 62 82 63 83 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 64 84 manager.state(for: repoAArchived).notifications = [