native macOS codings agent orchestrator
6
fork

Configure Feed

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

Implement Phase A persistence hardening with TDD

onevclaw 17919f87 6b23dcd5

+374 -43
+27 -7
supacode/Clients/Repositories/RepositoryPersistenceClient.swift
··· 101 101 } 102 102 }, 103 103 loadRepositorySnapshot: { 104 + try? SupacodePaths.migrateLegacyCacheFilesIfNeeded() 104 105 let snapshotURL = SupacodePaths.repositorySnapshotURL 105 106 guard let data = try? Data(contentsOf: snapshotURL) else { 106 107 return nil 107 108 } 108 109 guard !data.isEmpty else { 110 + discardRepositorySnapshot(at: snapshotURL) 111 + return nil 112 + } 113 + guard data.count <= RepositorySnapshotCachePayload.maxSnapshotFileBytes else { 114 + repositoryPersistenceLogger.warning("Repository snapshot exceeded size fuse and was reset") 109 115 discardRepositorySnapshot(at: snapshotURL) 110 116 return nil 111 117 } ··· 137 143 } 138 144 }, 139 145 saveRepositorySnapshot: { repositories in 146 + try? SupacodePaths.migrateLegacyCacheFilesIfNeeded() 140 147 let snapshotURL = SupacodePaths.repositorySnapshotURL 141 148 guard !repositories.isEmpty else { 142 149 discardRepositorySnapshot(at: snapshotURL) ··· 144 151 } 145 152 do { 146 153 try FileManager.default.createDirectory( 147 - at: SupacodePaths.baseDirectory, 154 + at: SupacodePaths.cacheDirectory, 148 155 withIntermediateDirectories: true 149 156 ) 150 157 let encoder = JSONEncoder() ··· 155 162 let data = try await MainActor.run { 156 163 try encoder.encode(payload) 157 164 } 165 + guard data.count <= RepositorySnapshotCachePayload.maxSnapshotFileBytes else { 166 + repositoryPersistenceLogger.warning("Repository snapshot exceeded size fuse and was skipped") 167 + return 168 + } 158 169 try data.write(to: snapshotURL, options: .atomic) 159 170 } catch { 160 171 repositoryPersistenceLogger.warning( ··· 206 217 } 207 218 } 208 219 209 - struct RepositorySnapshotCachePayload: Codable, Equatable, Sendable { 210 - static let currentVersion = 2 220 + nonisolated struct RepositorySnapshotCachePayload: Codable, Equatable, Sendable { 221 + nonisolated static let currentVersion = 2 222 + nonisolated static let maxSnapshotFileBytes = 2 * 1024 * 1024 223 + nonisolated static let maxRepositories = 256 224 + nonisolated static let maxWorktreesPerRepository = 512 211 225 212 226 let version: Int 213 227 let repositories: [SnapshotRepository] ··· 223 237 guard version == Self.currentVersion, !repositories.isEmpty else { 224 238 return nil 225 239 } 240 + guard repositories.count <= Self.maxRepositories else { 241 + return nil 242 + } 226 243 227 244 var restored: [Repository] = [] 228 245 restored.reserveCapacity(repositories.count) ··· 239 256 } 240 257 241 258 extension RepositorySnapshotCachePayload { 242 - struct SnapshotRepository: Codable, Equatable, Sendable { 259 + nonisolated struct SnapshotRepository: Codable, Equatable, Sendable { 243 260 let rootPath: String 244 261 let name: String 245 262 let kind: Repository.Kind ··· 258 275 guard let normalizedRootPath = normalizePath(rootPath), pathExists(normalizedRootPath) else { 259 276 return nil 260 277 } 278 + guard worktrees.count <= RepositorySnapshotCachePayload.maxWorktreesPerRepository else { 279 + return nil 280 + } 261 281 262 282 let rootURL = URL(fileURLWithPath: normalizedRootPath).standardizedFileURL 263 283 var restoredWorktrees: [Worktree] = [] ··· 286 306 } 287 307 } 288 308 289 - struct SnapshotWorktree: Codable, Equatable, Sendable { 309 + nonisolated struct SnapshotWorktree: Codable, Equatable, Sendable { 290 310 let name: String 291 311 let detail: String 292 312 let workingDirectoryPath: String ··· 320 340 } 321 341 } 322 342 323 - private func normalizePath(_ path: String) -> String? { 324 - RepositoryPathNormalizer.normalize([path]).first 343 + private nonisolated func normalizePath(_ path: String) -> String? { 344 + PathPolicy.normalizePath(path) 325 345 } 326 346 327 347 nonisolated enum RepositoryOrderNormalizer {
+35
supacode/Clients/Terminal/TerminalLayoutPersistenceClient.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + 4 + struct TerminalLayoutPersistenceClient { 5 + var clearSnapshot: @Sendable () async -> Bool 6 + } 7 + 8 + extension TerminalLayoutPersistenceClient: DependencyKey { 9 + static let liveValue = TerminalLayoutPersistenceClient( 10 + clearSnapshot: { 11 + do { 12 + try SupacodePaths.migrateLegacyCacheFilesIfNeeded() 13 + let url = SupacodePaths.terminalLayoutSnapshotURL 14 + guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) else { 15 + return true 16 + } 17 + try FileManager.default.removeItem(at: url) 18 + return true 19 + } catch { 20 + return false 21 + } 22 + } 23 + ) 24 + 25 + static let testValue = TerminalLayoutPersistenceClient( 26 + clearSnapshot: { true } 27 + ) 28 + } 29 + 30 + extension DependencyValues { 31 + var terminalLayoutPersistence: TerminalLayoutPersistenceClient { 32 + get { self[TerminalLayoutPersistenceClient.self] } 33 + set { self[TerminalLayoutPersistenceClient.self] = newValue } 34 + } 35 + }
+13
supacode/Features/App/Reducer/AppFeature.swift
··· 355 355 case .settings(.delegate(.terminalFontSizeChanged)): 356 356 return .none 357 357 358 + case .settings(.delegate(.terminalLayoutSnapshotCleared(let success))): 359 + if success { 360 + return .send(.repositories(.showToast(.success("Saved terminal layout cleared")))) 361 + } 362 + return .send( 363 + .repositories( 364 + .presentAlert( 365 + title: "Unable to clear saved terminal layout", 366 + message: "Please check file permissions and try again." 367 + ) 368 + ) 369 + ) 370 + 358 371 case .openActionSelectionChanged(let action): 359 372 state.openActionSelection = action 360 373 guard let worktree = state.repositories.selectedTerminalWorktree else {
+19 -8
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 89 89 var shouldSelectFirstAfterReload = false 90 90 var isRefreshingWorktrees = false 91 91 var statusToast: StatusToast? 92 + var snapshotPersistencePhase: SnapshotPersistencePhase = .idle 92 93 var githubIntegrationAvailability: GithubIntegrationAvailability = .unknown 93 94 var pendingPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] 94 95 var inFlightPullRequestRefreshRepositoryIDs: Set<Repository.ID> = [] ··· 271 272 case success(String) 272 273 } 273 274 275 + enum SnapshotPersistencePhase: Equatable { 276 + case idle 277 + case restoring 278 + case active 279 + } 280 + 274 281 enum Alert: Equatable { 275 282 case confirmArchiveWorktree(Worktree.ID, Repository.ID) 276 283 case confirmArchiveWorktrees([ArchiveWorktreeTarget]) ··· 311 318 Reduce { state, action in 312 319 switch action { 313 320 case .task: 321 + state.snapshotPersistencePhase = .restoring 314 322 return .run { send in 315 323 let pinned = await repositoryPersistence.loadPinnedWorktreeIDs() 316 324 let archived = await repositoryPersistence.loadArchivedWorktreeIDs() ··· 422 430 423 431 case .repositoriesLoaded(let repositories, let failures, let roots, let animated): 424 432 state.isRefreshingWorktrees = false 433 + let wasRestoringSnapshot = state.snapshotPersistencePhase == .restoring 434 + if failures.isEmpty, state.snapshotPersistencePhase != .active { 435 + state.snapshotPersistencePhase = .active 436 + } 425 437 let previousSelection = state.selectedWorktreeID 426 438 let previousSelectedWorktree = state.worktree(for: previousSelection) 427 439 let incomingRepositories = IdentifiedArray(uniqueElements: repositories) ··· 481 493 } 482 494 ) 483 495 } 484 - if failures.isEmpty { 496 + if failures.isEmpty, !wasRestoringSnapshot { 485 497 let repositories = Array(state.repositories) 486 498 allEffects.append( 487 499 .run { _ in ··· 553 565 let roots 554 566 ): 555 567 state.isRefreshingWorktrees = false 568 + let wasRestoringSnapshot = state.snapshotPersistencePhase == .restoring 569 + if failures.isEmpty, state.snapshotPersistencePhase != .active { 570 + state.snapshotPersistencePhase = .active 571 + } 556 572 let previousSelection = state.selectedWorktreeID 557 573 let previousSelectedWorktree = state.worktree(for: previousSelection) 558 574 let applyResult = applyRepositories( ··· 616 632 } 617 633 ) 618 634 } 619 - if failures.isEmpty { 635 + if failures.isEmpty, !wasRestoringSnapshot { 620 636 let repositories = Array(state.repositories) 621 637 allEffects.append( 622 638 .run { _ in ··· 3605 3621 } 3606 3622 3607 3623 private func isPathInsideBaseDirectory(_ path: URL, baseDirectory: URL) -> Bool { 3608 - let normalizedPath = path.standardizedFileURL.pathComponents 3609 - let normalizedBase = baseDirectory.standardizedFileURL.pathComponents 3610 - guard normalizedPath.count >= normalizedBase.count else { 3611 - return false 3612 - } 3613 - return Array(normalizedPath.prefix(normalizedBase.count)) == normalizedBase 3624 + PathPolicy.contains(path, in: baseDirectory) 3614 3625 } 3615 3626 3616 3627 private struct WorktreeCleanupStateResult {
+1 -5
supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift
··· 178 178 var normalized: [String] = [] 179 179 normalized.reserveCapacity(paths.count) 180 180 for path in paths { 181 - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) 182 - guard !trimmed.isEmpty else { continue } 183 - let resolved = URL(fileURLWithPath: trimmed) 184 - .standardizedFileURL 185 - .path(percentEncoded: false) 181 + guard let resolved = PathPolicy.normalizePath(path, resolvingSymlinks: false) else { continue } 186 182 if seen.insert(resolved).inserted { 187 183 normalized.append(resolved) 188 184 }
+7
supacode/Features/Settings/Models/GlobalSettings.swift
··· 18 18 var automaticallyArchiveMergedWorktrees: Bool 19 19 var promptForWorktreeCreation: Bool 20 20 var defaultWorktreeBaseDirectoryPath: String? 21 + var restoreTerminalLayoutOnLaunch: Bool 21 22 var terminalFontSize: Float32? 22 23 23 24 static let `default` = GlobalSettings( ··· 40 41 automaticallyArchiveMergedWorktrees: false, 41 42 promptForWorktreeCreation: true, 42 43 defaultWorktreeBaseDirectoryPath: nil, 44 + restoreTerminalLayoutOnLaunch: false, 43 45 terminalFontSize: nil 44 46 ) 45 47 ··· 63 65 automaticallyArchiveMergedWorktrees: Bool, 64 66 promptForWorktreeCreation: Bool, 65 67 defaultWorktreeBaseDirectoryPath: String? = nil, 68 + restoreTerminalLayoutOnLaunch: Bool = false, 66 69 terminalFontSize: Float32? = nil 67 70 ) { 68 71 self.appearanceMode = appearanceMode ··· 84 87 self.automaticallyArchiveMergedWorktrees = automaticallyArchiveMergedWorktrees 85 88 self.promptForWorktreeCreation = promptForWorktreeCreation 86 89 self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 90 + self.restoreTerminalLayoutOnLaunch = restoreTerminalLayoutOnLaunch 87 91 self.terminalFontSize = terminalFontSize 88 92 } 89 93 ··· 140 144 defaultWorktreeBaseDirectoryPath = 141 145 try container.decodeIfPresent(String.self, forKey: .defaultWorktreeBaseDirectoryPath) 142 146 ?? Self.default.defaultWorktreeBaseDirectoryPath 147 + restoreTerminalLayoutOnLaunch = 148 + try container.decodeIfPresent(Bool.self, forKey: .restoreTerminalLayoutOnLaunch) 149 + ?? Self.default.restoreTerminalLayoutOnLaunch 143 150 terminalFontSize = 144 151 try container.decodeIfPresent(Float32.self, forKey: .terminalFontSize) 145 152 ?? Self.default.terminalFontSize
+13
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 24 24 var automaticallyArchiveMergedWorktrees: Bool 25 25 var promptForWorktreeCreation: Bool 26 26 var defaultWorktreeBaseDirectoryPath: String 27 + var restoreTerminalLayoutOnLaunch: Bool 27 28 var terminalFontSize: Float32? 28 29 var selection: SettingsSection? = .general 29 30 var repositorySettings: RepositorySettingsFeature.State? ··· 51 52 promptForWorktreeCreation = settings.promptForWorktreeCreation 52 53 defaultWorktreeBaseDirectoryPath = 53 54 SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" 55 + restoreTerminalLayoutOnLaunch = settings.restoreTerminalLayoutOnLaunch 54 56 terminalFontSize = settings.terminalFontSize 55 57 } 56 58 ··· 77 79 defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( 78 80 defaultWorktreeBaseDirectoryPath 79 81 ), 82 + restoreTerminalLayoutOnLaunch: restoreTerminalLayoutOnLaunch, 80 83 terminalFontSize: terminalFontSize 81 84 ) 82 85 } ··· 89 92 case setSystemNotificationsEnabled(Bool) 90 93 case setCommandFinishedNotificationThreshold(String) 91 94 case setTerminalFontSize(Float32?) 95 + case clearTerminalLayoutSnapshotButtonTapped 92 96 case showNotificationPermissionAlert(errorMessage: String?) 93 97 case repositorySettings(RepositorySettingsFeature.Action) 94 98 case alert(PresentationAction<Alert>) ··· 105 109 enum Delegate: Equatable { 106 110 case settingsChanged(GlobalSettings) 107 111 case terminalFontSizeChanged(Float32?) 112 + case terminalLayoutSnapshotCleared(success: Bool) 108 113 } 109 114 110 115 @Dependency(AnalyticsClient.self) private var analyticsClient 111 116 @Dependency(SystemNotificationClient.self) private var systemNotificationClient 117 + @Dependency(TerminalLayoutPersistenceClient.self) private var terminalLayoutPersistence 112 118 113 119 var body: some Reducer<State, Action> { 114 120 BindingReducer() ··· 154 160 state.automaticallyArchiveMergedWorktrees = normalizedSettings.automaticallyArchiveMergedWorktrees 155 161 state.promptForWorktreeCreation = normalizedSettings.promptForWorktreeCreation 156 162 state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" 163 + state.restoreTerminalLayoutOnLaunch = normalizedSettings.restoreTerminalLayoutOnLaunch 157 164 state.terminalFontSize = normalizedSettings.terminalFontSize 158 165 state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 159 166 normalizedSettings.defaultWorktreeBaseDirectoryPath ··· 188 195 persist(state, captureAnalytics: false, emitSettingsChanged: false), 189 196 .send(.delegate(.terminalFontSizeChanged(fontSize))) 190 197 ) 198 + 199 + case .clearTerminalLayoutSnapshotButtonTapped: 200 + return .run { send in 201 + let success = await terminalLayoutPersistence.clearSnapshot() 202 + await send(.delegate(.terminalLayoutSnapshotCleared(success: success))) 203 + } 191 204 192 205 case .showNotificationPermissionAlert(let errorMessage): 193 206 let message: String
+16
supacode/Features/Settings/Views/AdvancedSettingsView.swift
··· 22 22 .font(.callout) 23 23 } 24 24 .frame(maxWidth: .infinity, alignment: .leading) 25 + 25 26 VStack(alignment: .leading) { 26 27 Toggle( 27 28 "Share crash reports with Prowl", ··· 34 35 Text("Requires app restart.") 35 36 .foregroundStyle(.secondary) 36 37 .font(.callout) 38 + } 39 + .frame(maxWidth: .infinity, alignment: .leading) 40 + 41 + VStack(alignment: .leading, spacing: 8) { 42 + Toggle( 43 + "Restore terminal layout on launch (experimental)", 44 + isOn: $store.restoreTerminalLayoutOnLaunch 45 + ) 46 + Text("When enabled, Prowl attempts to restore tabs and splits after restart.") 47 + .foregroundStyle(.secondary) 48 + .font(.callout) 49 + Button("Clear saved terminal layout") { 50 + store.send(.clearTerminalLayoutSnapshotButtonTapped) 51 + } 52 + .buttonStyle(.bordered) 37 53 } 38 54 .frame(maxWidth: .infinity, alignment: .leading) 39 55 }
+48
supacode/Support/PathPolicy.swift
··· 1 + import Foundation 2 + 3 + nonisolated enum PathPolicy { 4 + static func normalizePath( 5 + _ rawPath: String, 6 + relativeTo baseURL: URL? = nil, 7 + resolvingSymlinks: Bool = true 8 + ) -> String? { 9 + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) 10 + guard !trimmed.isEmpty else { 11 + return nil 12 + } 13 + let expanded = NSString(string: trimmed).expandingTildeInPath 14 + let url: URL 15 + if expanded.hasPrefix("/") { 16 + url = URL(fileURLWithPath: expanded) 17 + } else if let baseURL { 18 + url = baseURL.standardizedFileURL.appending(path: expanded, directoryHint: .inferFromPath) 19 + } else { 20 + url = FileManager.default.homeDirectoryForCurrentUser 21 + .appending(path: expanded, directoryHint: .inferFromPath) 22 + } 23 + return normalizeURL(url, resolvingSymlinks: resolvingSymlinks) 24 + .path(percentEncoded: false) 25 + } 26 + 27 + static func normalizeURL( 28 + _ url: URL, 29 + resolvingSymlinks: Bool = true 30 + ) -> URL { 31 + var normalized = url.standardizedFileURL 32 + if resolvingSymlinks, 33 + FileManager.default.fileExists(atPath: normalized.path(percentEncoded: false)) 34 + { 35 + normalized = normalized.resolvingSymlinksInPath().standardizedFileURL 36 + } 37 + return normalized 38 + } 39 + 40 + static func contains(_ path: URL, in baseDirectory: URL) -> Bool { 41 + let normalizedPath = normalizeURL(path).pathComponents 42 + let normalizedBase = normalizeURL(baseDirectory).pathComponents 43 + guard normalizedPath.count >= normalizedBase.count else { 44 + return false 45 + } 46 + return Array(normalizedPath.prefix(normalizedBase.count)) == normalizedBase 47 + } 48 + }
+65 -22
supacode/Support/SupacodePaths.swift
··· 18 18 baseDirectory.appending(path: "repo", directoryHint: .isDirectory) 19 19 } 20 20 21 + static var appSupportDirectory: URL { 22 + let appSupport = 23 + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first 24 + ?? baseDirectory 25 + return appSupport 26 + .appending(path: "com.onevcat.prowl", directoryHint: .isDirectory) 27 + .standardizedFileURL 28 + } 29 + 30 + static var cacheDirectory: URL { 31 + appSupportDirectory.appending(path: "cache", directoryHint: .isDirectory) 32 + } 33 + 21 34 static var reposDirectory: URL { 22 35 baseDirectory.appending(path: "repos", directoryHint: .isDirectory) 23 36 } ··· 34 47 guard let rawPath else { 35 48 return nil 36 49 } 37 - let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) 38 - guard !trimmed.isEmpty else { 39 - return nil 40 - } 41 - let expanded = NSString(string: trimmed).expandingTildeInPath 42 - let directoryURL: URL 43 - if expanded.hasPrefix("/") { 44 - directoryURL = URL(filePath: expanded, directoryHint: .isDirectory) 45 - } else if let repositoryRootURL { 46 - directoryURL = repositoryRootURL.standardizedFileURL 47 - .appending(path: expanded, directoryHint: .isDirectory) 48 - } else { 49 - directoryURL = FileManager.default.homeDirectoryForCurrentUser 50 - .appending(path: expanded, directoryHint: .isDirectory) 51 - } 52 - return directoryURL.standardizedFileURL.path(percentEncoded: false) 50 + return PathPolicy.normalizePath( 51 + rawPath, 52 + relativeTo: repositoryRootURL, 53 + resolvingSymlinks: false 54 + ) 53 55 } 54 56 55 57 static func worktreeBaseDirectory( ··· 62 64 repositoryOverridePath, 63 65 repositoryRootURL: rootURL 64 66 ) { 65 - return URL(filePath: repositoryOverridePath, directoryHint: .isDirectory).standardizedFileURL 67 + return PathPolicy.normalizeURL( 68 + URL(filePath: repositoryOverridePath, directoryHint: .isDirectory), 69 + resolvingSymlinks: false 70 + ) 66 71 } 67 72 if let globalDefaultPath = normalizedWorktreeBaseDirectoryPath(globalDefaultPath) { 68 - return URL(filePath: globalDefaultPath, directoryHint: .isDirectory) 69 - .standardizedFileURL 70 - .appending(path: repositoryDirectoryName(for: rootURL), directoryHint: .isDirectory) 71 - .standardizedFileURL 73 + return PathPolicy.normalizeURL( 74 + URL(filePath: globalDefaultPath, directoryHint: .isDirectory), 75 + resolvingSymlinks: false 76 + ) 77 + .appending(path: repositoryDirectoryName(for: rootURL), directoryHint: .isDirectory) 78 + .standardizedFileURL 72 79 } 73 80 return repositoryDirectory(for: rootURL) 74 81 } ··· 94 101 } 95 102 96 103 static var repositorySnapshotURL: URL { 97 - baseDirectory.appending(path: "repository-snapshot.json", directoryHint: .notDirectory) 104 + cacheDirectory.appending(path: "repository-snapshot.json", directoryHint: .notDirectory) 105 + } 106 + 107 + static var terminalLayoutSnapshotURL: URL { 108 + cacheDirectory.appending(path: "terminal-layout-snapshot.json", directoryHint: .notDirectory) 98 109 } 99 110 100 111 static var repositoryEntriesURL: URL { 101 112 baseDirectory.appending(path: "repository-entries.json", directoryHint: .notDirectory) 113 + } 114 + 115 + static func migrateLegacyCacheFilesIfNeeded( 116 + fileManager: FileManager = .default, 117 + legacyDirectory: URL? = nil, 118 + cacheDirectory: URL? = nil 119 + ) throws { 120 + let sourceDirectory = (legacyDirectory ?? baseDirectory).standardizedFileURL 121 + let destinationDirectory = (cacheDirectory ?? self.cacheDirectory).standardizedFileURL 122 + try fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true) 123 + 124 + let fileNames = [ 125 + "repository-snapshot.json", 126 + "terminal-layout-snapshot.json", 127 + ] 128 + 129 + for name in fileNames { 130 + let legacyURL = sourceDirectory.appending(path: name, directoryHint: .notDirectory) 131 + let destinationURL = destinationDirectory.appending(path: name, directoryHint: .notDirectory) 132 + guard !fileManager.fileExists(atPath: destinationURL.path(percentEncoded: false)) else { 133 + continue 134 + } 135 + guard fileManager.fileExists(atPath: legacyURL.path(percentEncoded: false)) else { 136 + continue 137 + } 138 + do { 139 + try fileManager.moveItem(at: legacyURL, to: destinationURL) 140 + } catch { 141 + try fileManager.copyItem(at: legacyURL, to: destinationURL) 142 + try? fileManager.removeItem(at: legacyURL) 143 + } 144 + } 102 145 } 103 146 104 147 static func repositorySettingsURL(for rootURL: URL) -> URL {
+11
supacodeTests/AppFeatureSettingsChangedTests.swift
··· 55 55 #expect(sentTerminalCommands.value.isEmpty) 56 56 #expect(watcherCommands.value.isEmpty) 57 57 } 58 + 59 + @Test(.dependencies) func clearTerminalLayoutSnapshotShowsSuccessToast() async { 60 + let store = TestStore(initialState: AppFeature.State()) { 61 + AppFeature() 62 + } 63 + 64 + await store.send(.settings(.delegate(.terminalLayoutSnapshotCleared(success: true)))) 65 + await store.receive(\.repositories.showToast) { 66 + $0.repositories.statusToast = .success("Saved terminal layout cleared") 67 + } 68 + } 58 69 }
+35
supacodeTests/RepositoriesFeatureTests.swift
··· 489 489 #expect(savedEntries.value == expectedSavedEntries) 490 490 } 491 491 492 + @Test func repositoriesLoadedSkipsRepositorySnapshotPersistenceWhileRestoring() async { 493 + let repoRoot = "/tmp/repo" 494 + let worktree = makeWorktree(id: "\(repoRoot)/main", name: "main", repoRoot: repoRoot) 495 + let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 496 + let savedSnapshots = LockIsolated<[[Repository]]>([]) 497 + var state = RepositoriesFeature.State() 498 + state.snapshotPersistencePhase = .restoring 499 + 500 + let store = TestStore(initialState: state) { 501 + RepositoriesFeature() 502 + } withDependencies: { 503 + $0.repositoryPersistence.saveRepositorySnapshot = { repositories in 504 + savedSnapshots.withValue { $0.append(repositories) } 505 + } 506 + } 507 + 508 + await store.send( 509 + .repositoriesLoaded( 510 + [repository], 511 + failures: [], 512 + roots: [URL(fileURLWithPath: repoRoot)], 513 + animated: false 514 + ) 515 + ) { 516 + $0.repositories = [repository] 517 + $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] 518 + $0.isInitialLoadComplete = true 519 + $0.snapshotPersistencePhase = .active 520 + } 521 + await store.receive(\.delegate.repositoriesChanged) 522 + await store.finish() 523 + 524 + #expect(savedSnapshots.value.isEmpty) 525 + } 526 + 492 527 @Test func repositoriesLoadedPersistsRepositorySnapshotOnSuccess() async { 493 528 let repoRoot = "/tmp/repo" 494 529 let worktree = makeWorktree(id: "\(repoRoot)/main", name: "main", repoRoot: repoRoot)
+31
supacodeTests/RepositoryPathsTests.swift
··· 110 110 111 111 #expect(path == expectedPath) 112 112 } 113 + 114 + @Test func repositorySnapshotURLUsesAppSupportCacheDirectory() { 115 + let path = SupacodePaths.repositorySnapshotURL.path(percentEncoded: false) 116 + 117 + #expect(path.contains("/Library/Application Support/com.onevcat.prowl/cache/")) 118 + } 119 + 120 + @Test func migrateLegacyCacheMovesSnapshotFilesToCacheDirectory() throws { 121 + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory()) 122 + .appending(path: UUID().uuidString, directoryHint: .isDirectory) 123 + let legacyDirectory = tempRoot.appending(path: "legacy", directoryHint: .isDirectory) 124 + let cacheDirectory = tempRoot.appending(path: "cache", directoryHint: .isDirectory) 125 + let repositorySnapshot = legacyDirectory.appending(path: "repository-snapshot.json", directoryHint: .notDirectory) 126 + let terminalSnapshot = legacyDirectory.appending(path: "terminal-layout-snapshot.json", directoryHint: .notDirectory) 127 + 128 + try FileManager.default.createDirectory(at: legacyDirectory, withIntermediateDirectories: true) 129 + try Data("repo".utf8).write(to: repositorySnapshot) 130 + try Data("terminal".utf8).write(to: terminalSnapshot) 131 + 132 + try SupacodePaths.migrateLegacyCacheFilesIfNeeded( 133 + legacyDirectory: legacyDirectory, 134 + cacheDirectory: cacheDirectory 135 + ) 136 + 137 + #expect(FileManager.default.fileExists(atPath: cacheDirectory.appending(path: "repository-snapshot.json").path(percentEncoded: false))) 138 + #expect(FileManager.default.fileExists(atPath: cacheDirectory.appending(path: "terminal-layout-snapshot.json").path(percentEncoded: false))) 139 + #expect(!FileManager.default.fileExists(atPath: repositorySnapshot.path(percentEncoded: false))) 140 + #expect(!FileManager.default.fileExists(atPath: terminalSnapshot.path(percentEncoded: false))) 141 + 142 + try? FileManager.default.removeItem(at: tempRoot) 143 + } 113 144 }
+41 -1
supacodeTests/RepositoryPersistenceClientTests.swift
··· 89 89 repositoryRoots: ["/tmp/repo-a", "/tmp/repo-b/../repo-b"] 90 90 ) 91 91 92 - try withDependencies { 92 + withDependencies { 93 93 $0.settingsFileStorage = storage.storage 94 94 } operation: { 95 95 @Shared(.settingsFile) var settings: SettingsFile ··· 156 156 let restored = payload.restoreRepositories { path in 157 157 path == repoRoot 158 158 } 159 + 160 + #expect(restored == nil) 161 + } 162 + 163 + @Test func repositorySnapshotPayloadRejectsTooManyRepositories() { 164 + let repositories = (0..<RepositorySnapshotCachePayload.maxRepositories + 1).map { index in 165 + Repository( 166 + id: "/tmp/repo-\(index)", 167 + rootURL: URL(fileURLWithPath: "/tmp/repo-\(index)"), 168 + name: "repo-\(index)", 169 + worktrees: IdentifiedArray() 170 + ) 171 + } 172 + 173 + let payload = RepositorySnapshotCachePayload(repositories: repositories) 174 + let restored = payload.restoreRepositories { _ in true } 175 + 176 + #expect(restored == nil) 177 + } 178 + 179 + @Test func repositorySnapshotPayloadRejectsTooManyWorktreesPerRepository() { 180 + let repoRoot = "/tmp/repo" 181 + let worktrees = (0..<RepositorySnapshotCachePayload.maxWorktreesPerRepository + 1).map { index in 182 + Worktree( 183 + id: "\(repoRoot)/wt-\(index)", 184 + name: "wt-\(index)", 185 + detail: ".", 186 + workingDirectory: URL(fileURLWithPath: "\(repoRoot)/wt-\(index)"), 187 + repositoryRootURL: URL(fileURLWithPath: repoRoot) 188 + ) 189 + } 190 + let repository = Repository( 191 + id: repoRoot, 192 + rootURL: URL(fileURLWithPath: repoRoot), 193 + name: "repo", 194 + worktrees: IdentifiedArray(uniqueElements: worktrees) 195 + ) 196 + 197 + let payload = RepositorySnapshotCachePayload(repositories: [repository]) 198 + let restored = payload.restoreRepositories { _ in true } 159 199 160 200 #expect(restored == nil) 161 201 }
+11
supacodeTests/SettingsFeatureTests.swift
··· 302 302 #expect(settingsFile.global.terminalFontSize == 18) 303 303 #expect(capturedEvents.value.isEmpty) 304 304 } 305 + 306 + @Test(.dependencies) func clearTerminalLayoutSnapshotSendsDelegate() async { 307 + let store = TestStore(initialState: SettingsFeature.State()) { 308 + SettingsFeature() 309 + } withDependencies: { 310 + $0.terminalLayoutPersistence.clearSnapshot = { true } 311 + } 312 + 313 + await store.send(.clearTerminalLayoutSnapshotButtonTapped) 314 + await store.receive(\.delegate.terminalLayoutSnapshotCleared) 315 + } 305 316 }
+1
supacodeTests/SettingsFilePersistenceTests.swift
··· 111 111 #expect(settings.global.automaticallyArchiveMergedWorktrees == false) 112 112 #expect(settings.global.promptForWorktreeCreation == true) 113 113 #expect(settings.global.defaultWorktreeBaseDirectoryPath == nil) 114 + #expect(settings.global.restoreTerminalLayoutOnLaunch == false) 114 115 #expect(settings.global.defaultEditorID == OpenWorktreeAction.automaticSettingsID) 115 116 #expect(settings.repositoryRoots.isEmpty) 116 117 #expect(settings.pinnedWorktreeIDs.isEmpty)