diff --git a/MonitorLizard/Models/PullRequest.swift b/MonitorLizard/Models/PullRequest.swift index c23b9ee..2242554 100644 --- a/MonitorLizard/Models/PullRequest.swift +++ b/MonitorLizard/Models/PullRequest.swift @@ -47,7 +47,7 @@ enum PRType: String, Codable, Hashable { } } -struct PullRequest: Identifiable, Hashable { +struct PullRequest: Identifiable, Hashable, Codable { let number: Int let title: String let repository: RepositoryInfo @@ -75,16 +75,16 @@ struct PullRequest: Identifiable, Hashable { !statusChecks.isEmpty } - struct RepositoryInfo: Hashable { + struct RepositoryInfo: Hashable, Codable { let name: String let nameWithOwner: String } - struct Author: Hashable { + struct Author: Hashable, Codable { let login: String } - struct Label: Hashable, Identifiable { + struct Label: Hashable, Identifiable, Codable { let id: String let name: String let color: String diff --git a/MonitorLizard/MonitorLizard.xcodeproj/project.pbxproj b/MonitorLizard/MonitorLizard.xcodeproj/project.pbxproj index ff1150b..f4465d0 100644 --- a/MonitorLizard/MonitorLizard.xcodeproj/project.pbxproj +++ b/MonitorLizard/MonitorLizard.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 3235E3782FB25D2B00A8B4D4 /* PRCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3235E3772FB25D2A00A8B4D4 /* PRCacheService.swift */; }; 32364F9E2F1143E100493ED7 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32364F9D2F1143E100493ED7 /* Constants.swift */; }; 324376112F5DB275009B7E2D /* UpdateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324376102F5DB275009B7E2D /* UpdateService.swift */; }; 3273F7A72F6DD65A005F7B77 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 324376122F5DB2A0009B7E2D /* Sparkle */; }; @@ -29,6 +30,7 @@ 32TESTS032F4A000000000001 /* WindowOcclusionObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32TESTS032F4A000000000000 /* WindowOcclusionObserverTests.swift */; }; 32TESTS042F4A000000000001 /* GitHubServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32TESTS042F4A000000000000 /* GitHubServiceTests.swift */; }; 32TESTS052F4A000000000001 /* ShellExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32TESTS052F4A000000000000 /* ShellExecutorTests.swift */; }; + 32TESTS062F4A000000000001 /* PRCacheServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32TESTS062F4A000000000000 /* PRCacheServiceTests.swift */; }; A8C2D4E19F7B4A2D9E3B7C41 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7BF1FEEFD8924EFA85A1FE55 /* Assets.xcassets */; }; AA0001112F103E3200F0ABCD /* OtherPRsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0002222F103E3200F0ABCD /* OtherPRsService.swift */; }; AA0003332F103E3200F0ABCD /* AddPRView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0004442F103E3200F0ABCD /* AddPRView.swift */; }; @@ -46,6 +48,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 3235E3772FB25D2A00A8B4D4 /* PRCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRCacheService.swift; sourceTree = ""; }; 32364F9D2F1143E100493ED7 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 324376102F5DB275009B7E2D /* UpdateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateService.swift; sourceTree = ""; }; 329D9EB32F103DF700F0E6EA /* MonitorLizard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MonitorLizard.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -70,6 +73,7 @@ 32TESTS032F4A000000000000 /* WindowOcclusionObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowOcclusionObserverTests.swift; sourceTree = ""; }; 32TESTS042F4A000000000000 /* GitHubServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubServiceTests.swift; sourceTree = ""; }; 32TESTS052F4A000000000000 /* ShellExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellExecutorTests.swift; sourceTree = ""; }; + 32TESTS062F4A000000000000 /* PRCacheServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRCacheServiceTests.swift; sourceTree = ""; }; 7BF1FEEFD8924EFA85A1FE55 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; AA0002222F103E3200F0ABCD /* OtherPRsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherPRsService.swift; sourceTree = ""; }; AA0004442F103E3200F0ABCD /* AddPRView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPRView.swift; sourceTree = ""; }; @@ -152,6 +156,7 @@ 324376102F5DB275009B7E2D /* UpdateService.swift */, AA0002222F103E3200F0ABCD /* OtherPRsService.swift */, BB0002222F103E3200F0ABCD /* CustomNamesService.swift */, + 3235E3772FB25D2A00A8B4D4 /* PRCacheService.swift */, ); path = Services; sourceTree = ""; @@ -183,6 +188,7 @@ 32TESTS032F4A000000000000 /* WindowOcclusionObserverTests.swift */, 32TESTS042F4A000000000000 /* GitHubServiceTests.swift */, 32TESTS052F4A000000000000 /* ShellExecutorTests.swift */, + 32TESTS062F4A000000000000 /* PRCacheServiceTests.swift */, ); name = MonitorLizardTests; path = ../MonitorLizardTests; @@ -302,6 +308,7 @@ 32DEMO9E2F103E3200F0E6EB /* DemoData.swift in Sources */, 329D9EF02F103E3200F0E6EA /* MonitorLizardApp.swift in Sources */, 329D9EF12F103E3200F0E6EA /* GitHubService.swift in Sources */, + 3235E3782FB25D2B00A8B4D4 /* PRCacheService.swift in Sources */, 329D9EF22F103E3200F0E6EA /* NotificationService.swift in Sources */, 329D9EF32F103E3200F0E6EA /* ShellExecutor.swift in Sources */, 329D9EF42F103E3200F0E6EA /* WatchlistService.swift in Sources */, @@ -327,6 +334,7 @@ 32TESTS032F4A000000000001 /* WindowOcclusionObserverTests.swift in Sources */, 32TESTS042F4A000000000001 /* GitHubServiceTests.swift in Sources */, 32TESTS052F4A000000000001 /* ShellExecutorTests.swift in Sources */, + 32TESTS062F4A000000000001 /* PRCacheServiceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MonitorLizard/Services/PRCacheService.swift b/MonitorLizard/Services/PRCacheService.swift new file mode 100644 index 0000000..8482aae --- /dev/null +++ b/MonitorLizard/Services/PRCacheService.swift @@ -0,0 +1,41 @@ +import Foundation + +final class PRCacheService { + private let defaults: UserDefaults + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + // In-session hash to skip redundant UserDefaults writes. + // Note: Data.hashValue seed resets per process, so these are not persisted. + private var lastMainHash: Int? + private var lastOtherHash: Int? + + private enum Key { + static let mainPRs = "cachedMainPRs" + static let otherPRs = "cachedOtherPRs" + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func save(mainPRs: [PullRequest], otherPRs: [PullRequest]) { + if let data = try? encoder.encode(mainPRs), data.hashValue != lastMainHash { + defaults.set(data, forKey: Key.mainPRs) + lastMainHash = data.hashValue + } + if let data = try? encoder.encode(otherPRs), data.hashValue != lastOtherHash { + defaults.set(data, forKey: Key.otherPRs) + lastOtherHash = data.hashValue + } + } + + func loadMainPRs() -> [PullRequest] { load(forKey: Key.mainPRs) } + func loadOtherPRs() -> [PullRequest] { load(forKey: Key.otherPRs) } + + private func load(forKey key: String) -> [T] { + guard let data = defaults.data(forKey: key), + let values = try? decoder.decode([T].self, from: data) else { return [] } + return values + } +} diff --git a/MonitorLizard/ViewModels/PRMonitorViewModel.swift b/MonitorLizard/ViewModels/PRMonitorViewModel.swift index 1527644..001ef7c 100644 --- a/MonitorLizard/ViewModels/PRMonitorViewModel.swift +++ b/MonitorLizard/ViewModels/PRMonitorViewModel.swift @@ -35,6 +35,7 @@ class PRMonitorViewModel: ObservableObject { private let notificationService = NotificationService.shared private let otherPRsService: OtherPRsService private let customNamesService: CustomNamesService + private let cacheService: PRCacheService private var refreshTimer: Timer? private var sortSettingObserver: AnyCancellable? @@ -84,12 +85,15 @@ class PRMonitorViewModel: ObservableObject { init(isDemoMode: Bool = false, watchlistService: WatchlistService? = nil, otherPRsService: OtherPRsService? = nil, - customNamesService: CustomNamesService? = nil) { + customNamesService: CustomNamesService? = nil, + cacheService: PRCacheService? = nil) { self.isDemoMode = isDemoMode self.githubService = GitHubService(isDemoMode: isDemoMode) self.watchlistService = watchlistService ?? .shared self.otherPRsService = otherPRsService ?? OtherPRsService() self.customNamesService = customNamesService ?? CustomNamesService() + self.cacheService = cacheService ?? PRCacheService() + restoreFromCache() setupNotifications() startPolling() observeSortSetting() @@ -104,6 +108,19 @@ class PRMonitorViewModel: ObservableObject { } } + private func restoreFromCache() { + let cached = cacheService.loadMainPRs() + if !cached.isEmpty { + unsortedPullRequests = cached.map { + var pr = $0; pr.isWatched = watchlistService.isWatched(pr); return pr + } + applySorting() + } + otherPullRequests = cacheService.loadOtherPRs().map { + var pr = $0; pr.isWatched = watchlistService.isWatched(pr); return pr + } + } + private func observeSortSetting() { sortSettingObserver = UserDefaults.standard .publisher(for: \.sortNonSuccessFirst) @@ -216,6 +233,8 @@ class PRMonitorViewModel: ObservableObject { selectedRepository = "All Repositories" } + cacheService.save(mainPRs: unsortedPullRequests, otherPRs: otherPullRequests) + lastRefreshTime = Date() isGHAvailable = true diff --git a/MonitorLizardTests/PRCacheServiceTests.swift b/MonitorLizardTests/PRCacheServiceTests.swift new file mode 100644 index 0000000..15d7976 --- /dev/null +++ b/MonitorLizardTests/PRCacheServiceTests.swift @@ -0,0 +1,123 @@ +import Testing +import Foundation +@testable import MonitorLizard + +@MainActor +struct PRCacheServiceTests { + + private func makeService() -> PRCacheService { + let suite = UserDefaults(suiteName: "test-\(UUID().uuidString)")! + return PRCacheService(defaults: suite) + } + + private func makePR(number: Int, isWatched: Bool = false, type: PRType = .authored) -> PullRequest { + PullRequest( + number: number, + title: "Test PR #\(number)", + repository: PullRequest.RepositoryInfo(name: "repo", nameWithOwner: "owner/repo"), + url: "https://github.com/owner/repo/pull/\(number)", + author: PullRequest.Author(login: "testuser"), + headRefName: "feature/test", + updatedAt: Date(timeIntervalSince1970: 1_000_000), + buildStatus: .success, + isWatched: isWatched, + labels: [], + type: type, + isDraft: false, + statusChecks: [], + reviewDecision: nil, + host: "github.com", + customName: nil + ) + } + + // MARK: Empty state + + @Test func emptyOnFirstLaunch() { + let service = makeService() + #expect(service.loadMainPRs().isEmpty) + #expect(service.loadOtherPRs().isEmpty) + } + + // MARK: Round-trip + + @Test func roundTripMainPRs() { + let service = makeService() + service.save(mainPRs: [makePR(number: 1), makePR(number: 2)], otherPRs: []) + let loaded = service.loadMainPRs() + #expect(loaded.map(\.number) == [1, 2]) + } + + @Test func roundTripOtherPRs() { + let service = makeService() + service.save(mainPRs: [], otherPRs: [makePR(number: 3, type: .other)]) + let loaded = service.loadOtherPRs() + #expect(loaded.map(\.number) == [3]) + } + + @Test func preservesKeyFields() { + let service = makeService() + let pr = PullRequest( + number: 42, + title: "Important PR", + repository: PullRequest.RepositoryInfo(name: "myrepo", nameWithOwner: "org/myrepo"), + url: "https://github.com/org/myrepo/pull/42", + author: PullRequest.Author(login: "alice"), + headRefName: "feature/cool", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + buildStatus: .failure, + isWatched: true, + labels: [PullRequest.Label(id: "lb1", name: "bug", color: "d73a4a")], + type: .reviewing, + isDraft: true, + statusChecks: [], + reviewDecision: .approved, + host: "github.com", + customName: "My Custom Name" + ) + + service.save(mainPRs: [pr], otherPRs: []) + let loaded = service.loadMainPRs()[0] + + #expect(loaded.number == 42) + #expect(loaded.title == "Important PR") + #expect(loaded.repository.nameWithOwner == "org/myrepo") + #expect(loaded.author.login == "alice") + #expect(loaded.buildStatus == .failure) + #expect(loaded.isWatched == true) + #expect(loaded.labels.count == 1) + #expect(loaded.labels[0].name == "bug") + #expect(loaded.type == .reviewing) + #expect(loaded.isDraft == true) + #expect(loaded.reviewDecision == .approved) + #expect(loaded.customName == "My Custom Name") + } + + // MARK: Overwrite + + @Test func subsequentSaveOverwritesPrevious() { + let service = makeService() + service.save(mainPRs: [makePR(number: 1)], otherPRs: []) + service.save(mainPRs: [makePR(number: 2), makePR(number: 3)], otherPRs: []) + let loaded = service.loadMainPRs() + #expect(loaded.map(\.number) == [2, 3]) + } + + @Test func savingEmptyListClearsPreviousData() { + let service = makeService() + service.save(mainPRs: [makePR(number: 1)], otherPRs: [makePR(number: 2)]) + service.save(mainPRs: [], otherPRs: []) + #expect(service.loadMainPRs().isEmpty) + #expect(service.loadOtherPRs().isEmpty) + } + + // MARK: Hash guard + + @Test func hashGuardDoesNotCorruptDataOnRepeatedSave() { + let service = makeService() + let prs = [makePR(number: 1)] + service.save(mainPRs: prs, otherPRs: []) + service.save(mainPRs: prs, otherPRs: []) // same data — hash guard skips write + #expect(service.loadMainPRs().map(\.number) == [1]) + } +}