From 24154656f7aac92faf9738cfc6225de0de687708 Mon Sep 17 00:00:00 2001 From: Sean McMains Date: Mon, 11 May 2026 13:53:47 -0500 Subject: [PATCH 1/2] feat: persist PR cache to UserDefaults for instant startup display On launch, restores the previous session's PR list immediately so the menu bar shows real data while the first GitHub fetch is in flight, eliminating the blank-list flash on startup. - PullRequest (and nested types) gains Codable conformance - New PRCacheService saves/restores main and Other PR lists as JSON; uses an in-session Data.hashValue guard to skip redundant writes - PRMonitorViewModel restores from cache before first poll, then saves after each successful refresh Co-authored-by: Buqian Zheng Co-Authored-By: Claude Sonnet 4.6 --- MonitorLizard/Models/PullRequest.swift | 8 ++-- .../MonitorLizard.xcodeproj/project.pbxproj | 4 ++ MonitorLizard/Services/PRCacheService.swift | 44 +++++++++++++++++++ .../ViewModels/PRMonitorViewModel.swift | 17 ++++++- 4 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 MonitorLizard/Services/PRCacheService.swift 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..3b23eca 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 */; }; @@ -46,6 +47,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; }; @@ -152,6 +154,7 @@ 324376102F5DB275009B7E2D /* UpdateService.swift */, AA0002222F103E3200F0ABCD /* OtherPRsService.swift */, BB0002222F103E3200F0ABCD /* CustomNamesService.swift */, + 3235E3772FB25D2A00A8B4D4 /* PRCacheService.swift */, ); path = Services; sourceTree = ""; @@ -302,6 +305,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 */, diff --git a/MonitorLizard/Services/PRCacheService.swift b/MonitorLizard/Services/PRCacheService.swift new file mode 100644 index 0000000..306ea40 --- /dev/null +++ b/MonitorLizard/Services/PRCacheService.swift @@ -0,0 +1,44 @@ +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] { + guard let data = defaults.data(forKey: Key.mainPRs), + let prs = try? decoder.decode([PullRequest].self, from: data) else { return [] } + return prs + } + + func loadOtherPRs() -> [PullRequest] { + guard let data = defaults.data(forKey: Key.otherPRs), + let prs = try? decoder.decode([PullRequest].self, from: data) else { return [] } + return prs + } +} diff --git a/MonitorLizard/ViewModels/PRMonitorViewModel.swift b/MonitorLizard/ViewModels/PRMonitorViewModel.swift index 1527644..0cf8e3a 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,15 @@ class PRMonitorViewModel: ObservableObject { } } + private func restoreFromCache() { + let cached = cacheService.loadMainPRs() + if !cached.isEmpty { + unsortedPullRequests = cached + applySorting() + } + otherPullRequests = cacheService.loadOtherPRs() + } + private func observeSortSetting() { sortSettingObserver = UserDefaults.standard .publisher(for: \.sortNonSuccessFirst) @@ -216,6 +229,8 @@ class PRMonitorViewModel: ObservableObject { selectedRepository = "All Repositories" } + cacheService.save(mainPRs: unsortedPullRequests, otherPRs: otherPullRequests) + lastRefreshTime = Date() isGHAvailable = true From 9ae8d070d96f32b4a7108517872f39ea877729c0 Mon Sep 17 00:00:00 2001 From: Sean McMains Date: Mon, 11 May 2026 16:24:03 -0500 Subject: [PATCH 2/2] review: tests, isWatched staleness fix, load helper deduplication - Add PRCacheServiceTests (7 tests) covering round-trip, field fidelity, overwrite, empty-list clearing, and hash-guard correctness - Fix restoreFromCache() to re-apply WatchlistService truth over cached isWatched values, eliminating the stale-state window on startup - Extract private load(forKey:) helper in PRCacheService to remove the loadMainPRs/loadOtherPRs duplication Co-Authored-By: zhengbuqian Co-Authored-By: Claude Sonnet 4.6 --- .../MonitorLizard.xcodeproj/project.pbxproj | 4 + MonitorLizard/Services/PRCacheService.swift | 15 +-- .../ViewModels/PRMonitorViewModel.swift | 8 +- MonitorLizardTests/PRCacheServiceTests.swift | 123 ++++++++++++++++++ 4 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 MonitorLizardTests/PRCacheServiceTests.swift diff --git a/MonitorLizard/MonitorLizard.xcodeproj/project.pbxproj b/MonitorLizard/MonitorLizard.xcodeproj/project.pbxproj index 3b23eca..f4465d0 100644 --- a/MonitorLizard/MonitorLizard.xcodeproj/project.pbxproj +++ b/MonitorLizard/MonitorLizard.xcodeproj/project.pbxproj @@ -30,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 */; }; @@ -72,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 = ""; }; @@ -186,6 +188,7 @@ 32TESTS032F4A000000000000 /* WindowOcclusionObserverTests.swift */, 32TESTS042F4A000000000000 /* GitHubServiceTests.swift */, 32TESTS052F4A000000000000 /* ShellExecutorTests.swift */, + 32TESTS062F4A000000000000 /* PRCacheServiceTests.swift */, ); name = MonitorLizardTests; path = ../MonitorLizardTests; @@ -331,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 index 306ea40..8482aae 100644 --- a/MonitorLizard/Services/PRCacheService.swift +++ b/MonitorLizard/Services/PRCacheService.swift @@ -30,15 +30,12 @@ final class PRCacheService { } } - func loadMainPRs() -> [PullRequest] { - guard let data = defaults.data(forKey: Key.mainPRs), - let prs = try? decoder.decode([PullRequest].self, from: data) else { return [] } - return prs - } + func loadMainPRs() -> [PullRequest] { load(forKey: Key.mainPRs) } + func loadOtherPRs() -> [PullRequest] { load(forKey: Key.otherPRs) } - func loadOtherPRs() -> [PullRequest] { - guard let data = defaults.data(forKey: Key.otherPRs), - let prs = try? decoder.decode([PullRequest].self, from: data) else { return [] } - return prs + 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 0cf8e3a..001ef7c 100644 --- a/MonitorLizard/ViewModels/PRMonitorViewModel.swift +++ b/MonitorLizard/ViewModels/PRMonitorViewModel.swift @@ -111,10 +111,14 @@ class PRMonitorViewModel: ObservableObject { private func restoreFromCache() { let cached = cacheService.loadMainPRs() if !cached.isEmpty { - unsortedPullRequests = cached + unsortedPullRequests = cached.map { + var pr = $0; pr.isWatched = watchlistService.isWatched(pr); return pr + } applySorting() } - otherPullRequests = cacheService.loadOtherPRs() + otherPullRequests = cacheService.loadOtherPRs().map { + var pr = $0; pr.isWatched = watchlistService.isWatched(pr); return pr + } } private func observeSortSetting() { 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]) + } +}