diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..a0a1c6a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(xcodebuild:*)", + "Bash(find:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d89246..80d8718 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: Select XCode Version uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "16.1" + xcode-version: "latest-stable" - name: Build run: xcodebuild clean build analyze -scheme "PReek" -project "PReek.xcodeproj" -destination "generic/platform=${{ matrix.platform }}" CODE_SIGNING_ALLOWED=NO | xcpretty && exit ${PIPESTATUS[0]} @@ -36,6 +36,6 @@ jobs: - name: Select XCode Version uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "16.1" + xcode-version: "latest-stable" - name: Test run: xcodebuild test -scheme "PReek" -project "PReek.xcodeproj" -destination "platform=macOS,arch=arm64" CODE_SIGNING_ALLOWED=NO | xcpretty && exit ${PIPESTATUS[0]} diff --git a/.gitignore b/.gitignore index 622b487..ae96fe4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,6 @@ .AppleDouble .LSOverride -# Icon must end with two \r -Icon - # Thumbnails ._* @@ -52,3 +49,4 @@ xcuserdata/ # End of https://www.toptal.com/developers/gitignore/api/macos,xcode +build/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..99b1c2a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,131 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +PReek is a macOS/iOS SwiftUI application that brings GitHub Pull Request notifications directly into the macOS MenuBar. It fetches pull requests based on GitHub notifications and displays them in a clean, organized interface with vim-like keyboard shortcuts. + +## Development Commands + +### Building +```bash +# Build for macOS +xcodebuild clean build -scheme "PReek" -project "PReek.xcodeproj" -destination "generic/platform=macOS" CODE_SIGNING_ALLOWED=NO + +# Build for iOS +xcodebuild clean build -scheme "PReek" -project "PReek.xcodeproj" -destination "generic/platform=iOS" CODE_SIGNING_ALLOWED=NO + +# Build with analysis +xcodebuild clean build analyze -scheme "PReek" -project "PReek.xcodeproj" -destination "generic/platform=macOS" CODE_SIGNING_ALLOWED=NO +``` + +### Testing +```bash +# Run tests +xcodebuild test -scheme "PReek" -project "PReek.xcodeproj" -destination "platform=macOS,arch=arm64" CODE_SIGNING_ALLOWED=NO + +# Run tests with pretty output (requires xcpretty) +xcodebuild test -scheme "PReek" -project "PReek.xcodeproj" -destination "platform=macOS,arch=arm64" CODE_SIGNING_ALLOWED=NO | xcpretty +``` + +### Opening in Xcode +```bash +open PReek.xcodeproj +``` + +## Architecture Overview + +### Core Components + +**App Structure:** +- `PReekApp.swift` - Main app entry point, handles MenuBarExtra setup and lifecycle +- `ContentView.swift` - Root content view with navigation logic, handles macOS/iOS platform differences + +**Data Flow:** +- `PullRequestsViewModel.swift` - Central view model managing pull request state, GitHub API calls, and UI updates +- `ConfigViewModel.swift` - Manages app configuration and GitHub authentication +- `GitHubService.swift` - API service layer for GitHub REST and GraphQL APIs + +**Models:** +- `PullRequest.swift` - Core data model with status tracking and unread calculation +- `Event.swift`, `Comment.swift`, `User.swift`, `Repository.swift` - Supporting GitHub data models +- `Notification.swift` - GitHub notification model + +**Services:** +- GraphQL queries in `PullRequestsQuery.swift` and `ViewerQuery.swift` +- DTO mapping layer in `Services/DtoModelMapper/` for converting API responses to domain models +- `ConfigService.swift` - Configuration management with UserDefaults + +### Key Architectural Patterns + +**Cross-Platform Support:** +- Platform-specific UI code using `#if os(macOS)` / `#else` blocks +- macOS uses MenuBarExtra with NavigationStack, iOS uses TabView +- Shared view models and business logic across platforms + +**State Management:** +- SwiftUI `@ObservedObject` and `@StateObject` for reactive UI updates +- Combine publishers for filtering and data transformation +- `@AppStorage` for persistent user preferences + +**API Integration:** +- Dual GitHub API support (REST for notifications, GraphQL for pull request details) +- Enterprise GitHub support via configurable base URLs +- JWT-style token authentication + +**Unread State Calculation:** +- Complex logic in `PullRequest.calculateUnread()` comparing notification timestamps with event timestamps +- Tracks oldest unread events for each pull request + +### View Architecture + +**Main Screens:** +- `MainScreen.swift` - Primary pull request list interface +- `WelcomeScreen.swift` - Initial setup and authentication +- `SettingsScreen.swift` - Configuration management + +**Pull Request Views:** +- Two main list styles: `PullRequestsList.swift` (iOS-style) and `PullRequestsDisclosureGroupList.swift` (macOS-style) +- `PullRequestDetailView.swift` - Detailed view with comments, commits, and events +- Event display in `EventView.swift` and `CommentsView.swift` + +**Utility Components:** +- `ResourceIcon.swift` - GitHub status icons (open, closed, merged, draft) +- `TimeSensitiveText.swift` - Relative time display +- Keyboard navigation handler for vim-like shortcuts (j/k/g/G/space) + +## Testing + +Tests are located in `PReekTests/` and focus on: +- Data transformation logic (`ReviewThreadsCommentsToEventsTests.swift`) +- Unread state calculation (`PullRequestUnreadTests.swift`) + +## Dependencies + +Key external dependencies managed via Swift Package Manager: +- `MenuBarExtraAccess` - Enhanced MenuBar control +- `KeychainAccess` - Secure token storage +- `MarkdownUI` - Markdown rendering for comments +- `LinkHeaderParser` - GitHub API pagination support + +## Notable Implementation Details + +**GitHub API Integration:** +- Uses GitHub's notification API to determine which PRs to show (notifications-based approach) +- Requires `notifications` scope for basic functionality, `repo` scope for private repositories +- GraphQL queries fetch comprehensive PR data including timeline events and review comments + +**MenuBar Behavior:** +- Dynamic icon changes based on unread status (`MenuBarIcon` vs `MenuBarIconUnread`) +- Auto-focus and keyboard shortcut handling when menu opens +- 5-second delay before resetting navigation state when menu closes + +**Keyboard Navigation:** +- Vim-like navigation: j (down), k (up), g (top), G (bottom), space (toggle) +- Focus management across different UI states and screens + +**Release Process:** +- Automated DMG creation with signing and notarization in Xcode scheme post-actions +- Uses `create-dmg` npm package for DMG generation +- Full notarization workflow integrated into build process \ No newline at end of file diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 694db04..18bfcee 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1234,5 +1234,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/PReek.xcodeproj/project.pbxproj b/PReek.xcodeproj/project.pbxproj index 0e375fb..fc64275 100644 --- a/PReek.xcodeproj/project.pbxproj +++ b/PReek.xcodeproj/project.pbxproj @@ -43,6 +43,8 @@ 6C1CB6FD2C92344200305288 /* RevealSecureField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1CB6FC2C92342F00305288 /* RevealSecureField.swift */; }; 6C1F49B82CB5BB9B00C30677 /* SafeArrayIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1F49B72CB5BB9300C30677 /* SafeArrayIndex.swift */; }; 6C1F49B92CB5BB9B00C30677 /* SafeArrayIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1F49B72CB5BB9300C30677 /* SafeArrayIndex.swift */; }; + E300212E25D94E51B33AB453 /* PullRequestUnreadCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E300212F25D94E51B33AB454 /* PullRequestUnreadCalculator.swift */; }; + AB76BF5A49F34BF6B75F6415 /* PullRequestUnreadCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E300212F25D94E51B33AB454 /* PullRequestUnreadCalculator.swift */; }; 6C232DE72C08C51D00C003B1 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 6C232DE62C08C51D00C003B1 /* KeychainAccess */; }; 6C232DEB2C08C95E00C003B1 /* ConfigViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C232DEA2C08C95E00C003B1 /* ConfigViewModel.swift */; }; 6C232DED2C08CB9600C003B1 /* KeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C232DEC2C08CB9600C003B1 /* KeychainStorage.swift */; }; @@ -143,6 +145,7 @@ 6C1CB6F62C921CCF00305288 /* IfView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IfView.swift; sourceTree = ""; }; 6C1CB6FC2C92342F00305288 /* RevealSecureField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevealSecureField.swift; sourceTree = ""; }; 6C1F49B72CB5BB9300C30677 /* SafeArrayIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeArrayIndex.swift; sourceTree = ""; }; + E300212F25D94E51B33AB454 /* PullRequestUnreadCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullRequestUnreadCalculator.swift; sourceTree = ""; }; 6C232DEA2C08C95E00C003B1 /* ConfigViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigViewModel.swift; sourceTree = ""; }; 6C232DEC2C08CB9600C003B1 /* KeychainStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStorage.swift; sourceTree = ""; }; 6C232DEE2C08F6C600C003B1 /* ConfigService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigService.swift; sourceTree = ""; }; @@ -372,6 +375,7 @@ 6C3DA9F02D82334D0075AFBC /* systemColors.swift */, 6C84D9A92D1EF2F700355C11 /* DisableAutocapitalization.swift */, 6C1F49B72CB5BB9300C30677 /* SafeArrayIndex.swift */, + E300212F25D94E51B33AB454 /* PullRequestUnreadCalculator.swift */, 6C7C4CA22C087A9900B9B4DA /* IfStringInterpolation.swift */, 6C1CB6F62C921CCF00305288 /* IfView.swift */, 6C1CB6F32C921CAF00305288 /* CodableAppStorage.swift */, @@ -503,7 +507,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1600; - LastUpgradeCheck = 1540; + LastUpgradeCheck = 2600; TargetAttributes = { 6C1CB6CD2C920E2E00305288 = { CreatedOnToolsVersion = 16.0; @@ -578,6 +582,7 @@ 6C9322062D1317E200D6C057 /* ReadData.swift in Sources */, 6C3DA9F22D8233510075AFBC /* systemColors.swift in Sources */, 6C1F49B92CB5BB9B00C30677 /* SafeArrayIndex.swift in Sources */, + E300212E25D94E51B33AB453 /* PullRequestUnreadCalculator.swift in Sources */, 6C1CB6F52C921CB700305288 /* CodableAppStorage.swift in Sources */, 6C1CB6EC2C921B2100305288 /* mergeArray.swift in Sources */, 6C1CB6EB2C921AE500305288 /* PullRequestDto.swift in Sources */, @@ -628,6 +633,7 @@ 6C1CB6F42C921CB700305288 /* CodableAppStorage.swift in Sources */, 6CE563FB2C00862E008E9A4B /* DividedView.swift in Sources */, 6C1F49B82CB5BB9B00C30677 /* SafeArrayIndex.swift in Sources */, + AB76BF5A49F34BF6B75F6415 /* PullRequestUnreadCalculator.swift in Sources */, 6C232DEB2C08C95E00C003B1 /* ConfigViewModel.swift in Sources */, 6C1CB6F22C921C9900305288 /* CaseIterableDefaultsLast.swift in Sources */, 6C3DA9F12D8233510075AFBC /* systemColors.swift in Sources */, @@ -696,7 +702,6 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 68JNFYH5JF; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; @@ -712,7 +717,6 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 68JNFYH5JF; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; @@ -760,6 +764,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 68JNFYH5JF; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -783,6 +788,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -826,6 +832,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 68JNFYH5JF; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -842,6 +849,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_VERSION = 5.0; }; @@ -854,13 +862,15 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = PReek/PReek.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 68JNFYH5JF; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = PReek/Info.plist; @@ -869,10 +879,12 @@ INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR codes to import app configuration"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 0.5.4; PRODUCT_BUNDLE_IDENTIFIER = "de.max-heidinger.PReek"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -882,7 +894,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -893,13 +905,15 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = PReek/PReek.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 68JNFYH5JF; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = PReek/Info.plist; @@ -908,10 +922,12 @@ INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR codes to import app configuration"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 0.5.4; PRODUCT_BUNDLE_IDENTIFIER = "de.max-heidinger.PReek"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -921,7 +937,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; diff --git a/PReek.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PReek.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 60dda9f..7fe0b30 100644 --- a/PReek.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/PReek.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4dd07821c1e020ea2af1cbc1594b0abea72563f9847f50ba95dd2799967d766f", + "originHash" : "432cf2afe6340d49f9ab3a1e7bcb03b55e265938e9cd74dbb79a93fbdf3182b7", "pins" : [ { "identity" : "codescanner", @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/orchetect/MenuBarExtraAccess", "state" : { - "revision" : "f5896b47e15e114975897354c7e1082c51a2bffd", - "version" : "1.0.5" + "revision" : "707dff6f55217b3ef5b6be84ced3e83511d4df5c", + "version" : "1.2.2" } }, { @@ -51,8 +51,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/NetworkImage", "state" : { - "revision" : "7aff8d1b31148d32c5933d75557d42f6323ee3d1", - "version" : "6.0.0" + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "b97d09472e847a416629f026eceae0e2afcfad65", + "version" : "0.7.0" } }, { @@ -60,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/swift-markdown-ui", "state" : { - "revision" : "55441810c0f678c78ed7e2ebd46dde89228e02fc", - "version" : "2.4.0" + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" } } ], diff --git a/PReek.xcodeproj/xcshareddata/xcschemes/PReek.xcscheme b/PReek.xcodeproj/xcshareddata/xcschemes/PReek.xcscheme index c227f9d..0e058f3 100644 --- a/PReek.xcodeproj/xcshareddata/xcschemes/PReek.xcscheme +++ b/PReek.xcodeproj/xcshareddata/xcschemes/PReek.xcscheme @@ -1,6 +1,6 @@ Event { diff --git a/PReek/Models/PullRequest.swift b/PReek/Models/PullRequest.swift index 2ad2050..4fea350 100644 --- a/PReek/Models/PullRequest.swift +++ b/PReek/Models/PullRequest.swift @@ -25,50 +25,6 @@ struct PullRequest: Identifiable, Equatable { var unread = true var oldestUnreadEvent: Event? = nil - mutating func calculateUnread(viewer: Viewer?, readData: ReadData?) { - guard let readData else { - unread = true - oldestUnreadEvent = nil - return - } - - if calculateUnreadFromEventId(viewer: viewer, lastMarkedAsReadEventId: readData.eventId) { - return - } - - // Fallback to time based approach in case it could not be calculated from the event id (non existent / event no longer available) - let nonViewerEvents = events.filter { event in event.user.login != viewer?.login } - let lastMarkedAsReadComparisonDate = nonViewerEvents.first?.time ?? lastUpdated // Ignore updates from viewer, take first non-viewer event as reference to compare - unread = readData.date < lastMarkedAsReadComparisonDate - oldestUnreadEvent = nonViewerEvents.reversed().first { event in - event.time > readData.date - } - } - - private mutating func calculateUnreadFromEventId(viewer: Viewer?, lastMarkedAsReadEventId: Event.ID?) -> Bool { - guard let lastMarkedAsReadEventId else { - return false - } - - let lastReadEventIndex = events.firstIndex(where: { $0.id == lastMarkedAsReadEventId }) - guard let lastReadEventIndex else { - return false - } - - let newerNonViewerEvents = Array(events.prefix(upTo: lastReadEventIndex)) - .filter { event in event.user.login != viewer?.login } - - if newerNonViewerEvents.count == 0 { - unread = false - oldestUnreadEvent = nil - return true - } - - unread = true - oldestUnreadEvent = newerNonViewerEvents.last - return true - } - var isClosed: Bool { return status == .closed || status == .merged } diff --git a/PReek/PReek.entitlements b/PReek/PReek.entitlements index ee95ab7..0c67376 100644 --- a/PReek/PReek.entitlements +++ b/PReek/PReek.entitlements @@ -1,10 +1,5 @@ - - com.apple.security.app-sandbox - - com.apple.security.network.client - - + diff --git a/PReek/Services/ConfigService.swift b/PReek/Services/ConfigService.swift index af97019..9834351 100644 --- a/PReek/Services/ConfigService.swift +++ b/PReek/Services/ConfigService.swift @@ -9,9 +9,28 @@ class ConfigService { @AppStorage("deleteOnlyClosed") static var deleteOnlyClosed: Bool = true @AppStorage("excludedUsers") private static var excludedUsersStr: String = "" + + // Performance optimization: Cache excluded users as Set for O(1) lookups + private static var excludedUsersCache: Set? + private static var lastExcludedUsersStr: String = "" + + static var excludedUsersSet: Set { + if excludedUsersStr != lastExcludedUsersStr || excludedUsersCache == nil { + excludedUsersCache = Set(excludedUsersStr.split(separator: "|").map { String($0) }) + lastExcludedUsersStr = excludedUsersStr + } + return excludedUsersCache! + } + static var excludedUsers: [String] { - set { excludedUsersStr = newValue.joined(separator: "|") } - get { return excludedUsersStr.split(separator: "|").map { subString in String(subString) } } + set { + excludedUsersStr = newValue.joined(separator: "|") + // Invalidate cache when setting new value + excludedUsersCache = nil + } + get { + return Array(excludedUsersSet) // Use the cached Set, converted to Array + } } @AppStorage("closeWindowOnLinkClick") static var closeWindowOnLinkClick: Bool = true diff --git a/PReek/Services/DtoModelMapper/timelineItemsToEvents.swift b/PReek/Services/DtoModelMapper/timelineItemsToEvents.swift index 1bcce4d..b604ae3 100644 --- a/PReek/Services/DtoModelMapper/timelineItemsToEvents.swift +++ b/PReek/Services/DtoModelMapper/timelineItemsToEvents.swift @@ -129,16 +129,19 @@ func timelineItemsToEvents(timelineItems: [PullRequestDto.TimelineItem]?, pullRe } // Step 1: Convert timeline items to data and merge information - let pairs = timelineItems.reduce(into: [TimelineItemEventDataPair]()) { result, timelineItem in - guard let _ = timelineItem.id else { - return + var pairs: [TimelineItemEventDataPair] = [] + pairs.reserveCapacity(timelineItems.count) + + for timelineItem in timelineItems { + guard timelineItem.id != nil else { + continue } - let pair = timelineItemToData(timelineItem: timelineItem, prevPair: result.last) - guard let pair else { - return + guard let pair = timelineItemToData(timelineItem: timelineItem, prevPair: pairs.last) else { + continue } - result.append(pair) + + pairs.append(pair) } // Step 2: Merge items if necessary diff --git a/PReek/Utility/PullRequestUnreadCalculator.swift b/PReek/Utility/PullRequestUnreadCalculator.swift new file mode 100644 index 0000000..d97e7f6 --- /dev/null +++ b/PReek/Utility/PullRequestUnreadCalculator.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Utility for calculating unread status externally without mutating PullRequest instances +enum PullRequestUnreadCalculator { + /// Result of unread calculation + struct UnreadResult { + let unread: Bool + let oldestUnreadEvent: Event? + } + + /// Calculate unread status for a pull request without mutating it + /// - Parameters: + /// - pullRequest: The pull request to analyze + /// - viewer: Current viewer information + /// - readData: Read status data for this PR + /// - Returns: UnreadResult containing calculated unread status and oldest unread event + static func calculateUnread( + for pullRequest: PullRequest, + viewer: Viewer?, + readData: ReadData? + ) -> UnreadResult { + guard let readData else { + return UnreadResult(unread: true, oldestUnreadEvent: nil) + } + + // Try event ID based calculation first + if let eventBasedResult = calculateUnreadFromEventId( + events: pullRequest.events, + viewer: viewer, + lastMarkedAsReadEventId: readData.eventId + ) { + return eventBasedResult + } + + // Fallback to time-based approach + return calculateUnreadFromDate( + events: pullRequest.events, + lastUpdated: pullRequest.lastUpdated, + viewer: viewer, + readDate: readData.date + ) + } + + /// Calculate unread status based on event ID + private static func calculateUnreadFromEventId( + events: [Event], + viewer: Viewer?, + lastMarkedAsReadEventId: Event.ID? + ) -> UnreadResult? { + guard let lastMarkedAsReadEventId else { + return nil + } + + let lastReadEventIndex = events.firstIndex(where: { $0.id == lastMarkedAsReadEventId }) + guard let lastReadEventIndex else { + return nil + } + + let newerNonViewerEvents = Array(events.prefix(upTo: lastReadEventIndex)) + .filter { event in event.user.login != viewer?.login } + + if newerNonViewerEvents.isEmpty { + return UnreadResult(unread: false, oldestUnreadEvent: nil) + } + + return UnreadResult(unread: true, oldestUnreadEvent: newerNonViewerEvents.last) + } + + /// Calculate unread status based on date comparison + private static func calculateUnreadFromDate( + events: [Event], + lastUpdated: Date, + viewer: Viewer?, + readDate: Date + ) -> UnreadResult { + let nonViewerEvents = events.filter { event in event.user.login != viewer?.login } + let lastMarkedAsReadComparisonDate = nonViewerEvents.first?.time ?? lastUpdated + + let unread = readDate < lastMarkedAsReadComparisonDate + let oldestUnreadEvent = nonViewerEvents.reversed().first { event in + event.time > readDate + } + + return UnreadResult(unread: unread, oldestUnreadEvent: oldestUnreadEvent) + } +} diff --git a/PReek/Utility/mergeArray.swift b/PReek/Utility/mergeArray.swift index 100d53d..d50d761 100644 --- a/PReek/Utility/mergeArray.swift +++ b/PReek/Utility/mergeArray.swift @@ -1,15 +1,18 @@ import Foundation func mergeArray(_ array: [T], indicator: KeyPath) -> [T] { - return array.reduce([T]()) { dataArray, element in + var result: [T] = [] + result.reserveCapacity(array.count) + + for element in array { let merge = element[keyPath: indicator] != nil - var newDataArray = dataArray - if !dataArray.isEmpty, merge { - newDataArray[newDataArray.endIndex - 1] = element + if !result.isEmpty, merge { + result[result.endIndex - 1] = element } else { - newDataArray.append(element) + result.append(element) } - return newDataArray } + + return result } diff --git a/PReek/ViewModel/PullRequestsViewModel.swift b/PReek/ViewModel/PullRequestsViewModel.swift index cbea424..1db7b6e 100644 --- a/PReek/ViewModel/PullRequestsViewModel.swift +++ b/PReek/ViewModel/PullRequestsViewModel.swift @@ -25,6 +25,7 @@ class PullRequestsViewModel: ObservableObject { showReadSubject = CurrentValueSubject(storedShowRead) setupPullRequestsMemoization() setupPullRequestFocus() + updatePullRequestIndexMap() } @Published private(set) var lastUpdated: Date? = nil @@ -46,7 +47,7 @@ class PullRequestsViewModel: ObservableObject { } } - @Published var lastFocusedPullRequestId: PullRequest.ID? + @Published var lastUIFocusedPullRequestId: PullRequest.ID? @Published var focusedPullRequestId: PullRequest.ID? var pullRequests: [PullRequest] { memoizedPullRequests @@ -57,7 +58,77 @@ class PullRequestsViewModel: ObservableObject { @Published private var memoizedPullRequests: [PullRequest] = [] private let invalidationTrigger = PassthroughSubject() + private var unreadCache: [String: (unread: Bool, oldestEvent: Event?)] = [:] + private var lastProcessedVersions: [String: TimeInterval] = [:] + private let setFocusTrigger = PassthroughSubject() + private var pullRequestIndexMap: [String: Int] = [:] + + private func updatePullRequestIndexMap() { + pullRequestIndexMap = Dictionary( + pullRequests.enumerated().map { index, pr in (pr.id, index) }, + uniquingKeysWith: { first, _ in first } + ) + } + + private func updateUnreadCacheIfNeeded() { + for (id, pr) in pullRequestMap { + let version = pr.lastUpdated.timeIntervalSince1970 + + // Only recalculate if PR has changed since last computation or if cache is empty + if lastProcessedVersions[id] != version || unreadCache[id] == nil { + let result = PullRequestUnreadCalculator.calculateUnread( + for: pr, + viewer: viewer, + readData: pullRequestReadMap[id] + ) + unreadCache[id] = (result.unread, result.oldestUnreadEvent) + lastProcessedVersions[id] = version + } + } + + // Clean up cache for removed PRs + let currentPRIds = Set(pullRequestMap.keys) + unreadCache = unreadCache.filter { currentPRIds.contains($0.key) } + lastProcessedVersions = lastProcessedVersions.filter { currentPRIds.contains($0.key) } + } + + private func getFilteredPullRequests(showClosed: Bool, showRead: Bool) -> ([PullRequest], Bool) { + updateUnreadCacheIfNeeded() + + var filteredPRs: [PullRequest] = [] + filteredPRs.reserveCapacity(pullRequestMap.count) + + for (_, pr) in pullRequestMap { + guard let cachedUnread = unreadCache[pr.id] else { continue } + + // Apply cached unread state + var updatedPR = pr + updatedPR.unread = cachedUnread.unread + updatedPR.oldestUnreadEvent = cachedUnread.oldestEvent + + // Check for filters + let passesClosedFilter = showClosed || !updatedPR.isClosed + let passesReadFilter = showRead || updatedPR.unread + + guard passesClosedFilter, passesReadFilter else { continue } + + // Check for excluded users + let containsNonExcludedUser = updatedPR.events.contains { event in + !ConfigService.excludedUsersSet.contains(event.user.login) + } + + guard containsNonExcludedUser else { continue } + + filteredPRs.append(updatedPR) + } + + filteredPRs.sort { $0.lastUpdated > $1.lastUpdated } + + let hasUnread = filteredPRs.contains { $0.unread } + + return (filteredPRs, hasUnread) + } private func setupPullRequestsMemoization() { // Combine dependencies that affect the pullRequests computation @@ -74,32 +145,13 @@ class PullRequestsViewModel: ObservableObject { .map { [weak self] showClosed, showRead, _, _ in guard let self = self else { return ([], false) } - let updatedRead = self.pullRequestMap.map { entry in - var pullRequest = entry.value - pullRequest.calculateUnread(viewer: self.viewer, readData: self.pullRequestReadMap[pullRequest.id]) - return pullRequest - } - let filtered = updatedRead.filter { pullRequest in - let containsNonExcludedUser = pullRequest.events.contains { event in - !ConfigService.excludedUsers.contains(event.user.login) - } - - let passesClosedFilter = showClosed || !pullRequest.isClosed - let passesReadFilter = showRead || pullRequest.unread - - return containsNonExcludedUser && passesClosedFilter && passesReadFilter - } - let filteredAndSorted = filtered.sorted { - $0.lastUpdated > $1.lastUpdated - } - - let hasUnread = filteredAndSorted.contains { $0.unread } - return (filteredAndSorted, hasUnread) + return self.getFilteredPullRequests(showClosed: showClosed, showRead: showRead) } .receive(on: DispatchQueue.main) .sink { [weak self] (pullRequests: [PullRequest], hasUnread: Bool) in self?.memoizedPullRequests = pullRequests self?.hasUnread = hasUnread + self?.updatePullRequestIndexMap() } .store(in: &cancellables) } @@ -126,6 +178,9 @@ class PullRequestsViewModel: ObservableObject { } else { pullRequestReadMap.removeValue(forKey: id) } + + // Invalidate cache for this specific PR + unreadCache.removeValue(forKey: id) invalidationTrigger.send() } @@ -134,28 +189,35 @@ class PullRequestsViewModel: ObservableObject { let newestEventId = pullRequest.events.first?.id pullRequestReadMap[pullRequest.id] = ReadData(date: lastUpdated ?? Date(), eventId: newestEventId) } + + // Clear unread cache as all items are now read + unreadCache.removeAll() invalidationTrigger.send() } private func setupPullRequestFocus() { setFocusTrigger - .throttle(for: .milliseconds(20), scheduler: DispatchQueue.main, latest: true) - .map { [weak self] type in - guard let self = self else { return nil } + .throttle(for: .milliseconds(40), scheduler: DispatchQueue.main, latest: true) + .receive(on: DispatchQueue.main) + .sink { [weak self] type in + guard let self = self else { return } + let newFocusId: String? switch type { case .first: - return self.pullRequests.first?.id + newFocusId = self.pullRequests.first?.id case .last: - return self.pullRequests.last?.id + newFocusId = self.pullRequests.last?.id case .next: - return self.getNextFocusIdByOffset(by: 1) + newFocusId = self.getNextFocusIdByOffset(by: 1) case .previous: - return self.getNextFocusIdByOffset(by: -1) + newFocusId = self.getNextFocusIdByOffset(by: -1) } + + // Update both focus IDs synchronously to avoid divergation + self.lastUIFocusedPullRequestId = newFocusId + self.focusedPullRequestId = newFocusId } - .receive(on: DispatchQueue.main) - .assign(to: \.focusedPullRequestId, on: self) .store(in: &cancellables) } @@ -164,20 +226,16 @@ class PullRequestsViewModel: ObservableObject { } private func getNextFocusIdByOffset(by offset: Int) -> String? { - if pullRequests.count == 0 { - focusedPullRequestId = nil - return nil - } + guard !pullRequests.isEmpty else { return nil } - let basePullRequestId = lastFocusedPullRequestId ?? focusedPullRequestId + let basePullRequestId = lastUIFocusedPullRequestId ?? focusedPullRequestId - // Calculate next PR by current focus let currentIndex = basePullRequestId.flatMap { focusedId in - pullRequests.firstIndex { $0.id == focusedId } + pullRequestIndexMap[focusedId] } let newIndex = ((currentIndex ?? (offset < 0 ? pullRequests.count : -1)) + offset + pullRequests.count) % pullRequests.count - return pullRequests[safe: newIndex].map { $0.id } + return pullRequests[safe: newIndex]?.id } private func handleReceivedNotifications(notifications: [Notification], viewer: Viewer) async throws -> [String] { @@ -203,6 +261,8 @@ class PullRequestsViewModel: ObservableObject { await MainActor.run { for pullRequest in pullRequests { self.pullRequestMap[pullRequest.id] = pullRequest + // Invalidate cache for updated PRs + self.unreadCache.removeValue(forKey: pullRequest.id) } self.invalidationTrigger.send() } diff --git a/PReek/Views/Main/MainScreen.swift b/PReek/Views/Main/MainScreen.swift index eac98c9..3bb7ae0 100644 --- a/PReek/Views/Main/MainScreen.swift +++ b/PReek/Views/Main/MainScreen.swift @@ -29,7 +29,7 @@ struct MainScreen: View { pullRequestsViewModel.pullRequests, setRead: pullRequestsViewModel.setRead, toBeFocusedPullRequestId: $pullRequestsViewModel.focusedPullRequestId, - lastFocusedPullRequestId: $pullRequestsViewModel.lastFocusedPullRequestId + lastUIFocusedPullRequestId: $pullRequestsViewModel.lastUIFocusedPullRequestId ) #else PullRequestsList(pullRequestsViewModel: pullRequestsViewModel, footer: { diff --git a/PReek/Views/PullRequestViews/CommentsView.swift b/PReek/Views/PullRequestViews/CommentsView.swift index 45c531f..7b88a72 100644 --- a/PReek/Views/PullRequestViews/CommentsView.swift +++ b/PReek/Views/PullRequestViews/CommentsView.swift @@ -5,7 +5,7 @@ struct CommentsView: View { var comments: [Comment] var body: some View { - VStack(alignment: .leading, spacing: 5) { + LazyVStack(alignment: .leading, spacing: 5) { ForEach(comments) { comment in CommentView(comment: comment) } diff --git a/PReek/Views/PullRequestViews/CommitsView.swift b/PReek/Views/PullRequestViews/CommitsView.swift index bc6f52b..b24bcae 100644 --- a/PReek/Views/PullRequestViews/CommitsView.swift +++ b/PReek/Views/PullRequestViews/CommitsView.swift @@ -4,7 +4,7 @@ struct CommitsView: View { let commits: [Commit] var body: some View { - VStack(alignment: .leading) { + LazyVStack(alignment: .leading) { ForEach(commits) { commit in if let url = commit.url { HoverableLink(destination: url) { diff --git a/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestContentView.swift b/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestContentView.swift index 4049a62..396745f 100644 --- a/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestContentView.swift +++ b/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestContentView.swift @@ -1,6 +1,6 @@ import SwiftUI -struct PullRequestContentView: View { +struct PullRequestContentView: View, Equatable { @State var eventLimit = 0 var pullRequest: PullRequest @@ -10,6 +10,13 @@ struct PullRequestContentView: View { self.pullRequest = pullRequest } + static func == (lhs: PullRequestContentView, rhs: PullRequestContentView) -> Bool { + lhs.pullRequest.id == rhs.pullRequest.id && + lhs.pullRequest.events.count == rhs.pullRequest.events.count && + lhs.pullRequest.events.first?.id == rhs.pullRequest.events.first?.id && + lhs.pullRequest.events.last?.id == rhs.pullRequest.events.last?.id + } + func loadMore() { eventLimit = min(pullRequest.events.count, eventLimit + 5) } @@ -20,7 +27,7 @@ struct PullRequestContentView: View { } var eventsBody: some View { - VStack { + LazyVStack { DividedView(pullRequest.events[0 ..< eventLimit]) { event in EventView(event) } shouldHighlight: { event in @@ -41,12 +48,16 @@ struct PullRequestContentView: View { var body: some View { if pullRequest.events.isEmpty { noEventsBody + } else { + eventsBody } - eventsBody } } #Preview { - PullRequestContentView(PullRequest.preview()) - .padding() + ScrollView { + PullRequestContentView(PullRequest.preview()) + .padding() + } + .frame(height: 400) } diff --git a/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestDisclosureGroup.swift b/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestDisclosureGroup.swift index 56d4d09..6863331 100644 --- a/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestDisclosureGroup.swift +++ b/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestDisclosureGroup.swift @@ -1,6 +1,6 @@ import SwiftUI -struct PullRequestDisclosureGroup: View { +struct PullRequestDisclosureGroup: View, Equatable { var pullRequest: PullRequest var setRead: (PullRequest.ID, Bool) -> Void @@ -12,10 +12,21 @@ struct PullRequestDisclosureGroup: View { self.sectionExpanded = sectionExpanded } + static func == (lhs: PullRequestDisclosureGroup, rhs: PullRequestDisclosureGroup) -> Bool { + lhs.pullRequest.id == rhs.pullRequest.id && + lhs.pullRequest.unread == rhs.pullRequest.unread && + lhs.pullRequest.title == rhs.pullRequest.title && + lhs.pullRequest.lastUpdated == rhs.pullRequest.lastUpdated && + lhs.pullRequest.status == rhs.pullRequest.status + } + var body: some View { VStack { DisclosureGroup(isExpanded: $sectionExpanded) { - PullRequestContentView(pullRequest) + // Only create content view when expanded + if sectionExpanded { + PullRequestContentView(pullRequest) + } } label: { PullRequestHeaderView(pullRequest, setRead: setRead) .padding(.leading, 10) diff --git a/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestHeaderView.swift b/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestHeaderView.swift index fb404e8..4b21258 100644 --- a/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestHeaderView.swift +++ b/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestHeaderView.swift @@ -6,7 +6,7 @@ private func usersToString(_ users: [User]) -> String { }.joined(separator: "\n") } -struct PullRequestHeaderView: View { +struct PullRequestHeaderView: View, Equatable { var pullRequest: PullRequest var setRead: (PullRequest.ID, Bool) -> Void @@ -17,6 +17,14 @@ struct PullRequestHeaderView: View { self.setRead = setRead } + static func == (lhs: PullRequestHeaderView, rhs: PullRequestHeaderView) -> Bool { + lhs.pullRequest.id == rhs.pullRequest.id && + lhs.pullRequest.unread == rhs.pullRequest.unread && + lhs.pullRequest.title == rhs.pullRequest.title && + lhs.pullRequest.lastUpdated == rhs.pullRequest.lastUpdated && + lhs.pullRequest.status == rhs.pullRequest.status + } + var body: some View { HStack(spacing: 10) { StatusIcon(pullRequest.status) @@ -52,13 +60,21 @@ struct PullRequestHeaderView: View { } } + var authorText: some View { + Text("by \(pullRequest.author.displayName)") + .lineLimit(1) + .truncationMode(.tail) + } + var details: some View { HStack(spacing: 5) { Group { if let authorUrl = pullRequest.author.url { - HoverableLink("by \(pullRequest.author.displayName)", destination: authorUrl) + HoverableLink(destination: authorUrl) { + authorText + } } else { - Text("by \(pullRequest.author.displayName)") + authorText } } .if(pullRequest.author.login != pullRequest.author.displayName) { view in diff --git a/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestsDisclosureGroupList.swift b/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestsDisclosureGroupList.swift index 1ef8e1b..7d1e052 100644 --- a/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestsDisclosureGroupList.swift +++ b/PReek/Views/PullRequestViews/DisclosureGroupList/PullRequestsDisclosureGroupList.swift @@ -4,15 +4,15 @@ struct PullRequestsDisclosureGroupList: View { var pullRequests: [PullRequest] var setRead: (PullRequest.ID, Bool) -> Void @Binding var toBeFocusedPullRequestId: PullRequest.ID? - @Binding var lastFocusedPullRequestId: PullRequest.ID? + @Binding var lastUIFocusedPullRequestId: PullRequest.ID? @FocusState var focusedPullRequestId: PullRequest.ID? - init(_ pullRequests: [PullRequest], setRead: @escaping (PullRequest.ID, Bool) -> Void, toBeFocusedPullRequestId: Binding, lastFocusedPullRequestId: Binding) { + init(_ pullRequests: [PullRequest], setRead: @escaping (PullRequest.ID, Bool) -> Void, toBeFocusedPullRequestId: Binding, lastUIFocusedPullRequestId: Binding) { self.pullRequests = pullRequests self.setRead = setRead _toBeFocusedPullRequestId = toBeFocusedPullRequestId - _lastFocusedPullRequestId = lastFocusedPullRequestId + _lastUIFocusedPullRequestId = lastUIFocusedPullRequestId } var body: some View { @@ -44,7 +44,7 @@ struct PullRequestsDisclosureGroupList: View { } .onChange(of: focusedPullRequestId) { _, newValue in if let id = newValue { - lastFocusedPullRequestId = id + lastUIFocusedPullRequestId = id } } } @@ -76,6 +76,6 @@ struct PullRequestsDisclosureGroupList: View { ], setRead: { _, _ in }, toBeFocusedPullRequestId: .constant(""), - lastFocusedPullRequestId: .constant("") + lastUIFocusedPullRequestId: .constant("") ) } diff --git a/PReek/Views/PullRequestViews/EventDataView.swift b/PReek/Views/PullRequestViews/EventDataView.swift index 0432eef..d44e3e4 100644 --- a/PReek/Views/PullRequestViews/EventDataView.swift +++ b/PReek/Views/PullRequestViews/EventDataView.swift @@ -10,11 +10,17 @@ struct EventDataView: View { var body: some View { switch data { case let pushedData as EventPushedData: - CommitsView(commits: pushedData.commits) + if !pushedData.commits.isEmpty { + CommitsView(commits: pushedData.commits) + } case let reviewData as EventReviewData: - CommentsView(comments: reviewData.comments) + if !reviewData.comments.isEmpty { + CommentsView(comments: reviewData.comments) + } case let commentData as EventCommentData: - CommentsView(comments: commentData.comments) + if !commentData.comments.isEmpty { + CommentsView(comments: commentData.comments) + } case let renamedTitleData as EventRenamedTitleData: HStack { VStack(alignment: .leading) { @@ -46,8 +52,6 @@ struct EventDataView: View { } } } - } else { - EmptyView() } default: EmptyView() diff --git a/PReek/Views/PullRequestViews/List/PullRequestDetailView.swift b/PReek/Views/PullRequestViews/List/PullRequestDetailView.swift index 673c7ea..fdbbc35 100644 --- a/PReek/Views/PullRequestViews/List/PullRequestDetailView.swift +++ b/PReek/Views/PullRequestViews/List/PullRequestDetailView.swift @@ -36,6 +36,7 @@ struct PullRequestDetailView: View { } #if os(iOS) .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden() .toolbar { ToolbarItem(placement: .topBarLeading) { VStack(alignment: .leading) { diff --git a/PReek/Views/PullRequestViews/List/PullRequestListItem.swift b/PReek/Views/PullRequestViews/List/PullRequestListItem.swift index 14c025e..1433bd5 100644 --- a/PReek/Views/PullRequestViews/List/PullRequestListItem.swift +++ b/PReek/Views/PullRequestViews/List/PullRequestListItem.swift @@ -1,12 +1,22 @@ import SwiftUI -struct PullRequestListItem: View { +struct PullRequestListItem: View, Equatable { var pullRequest: PullRequest init(_ pullRequest: PullRequest) { self.pullRequest = pullRequest } + static func == (lhs: PullRequestListItem, rhs: PullRequestListItem) -> Bool { + lhs.pullRequest.id == rhs.pullRequest.id && + lhs.pullRequest.unread == rhs.pullRequest.unread && + lhs.pullRequest.title == rhs.pullRequest.title && + lhs.pullRequest.lastUpdated == rhs.pullRequest.lastUpdated && + lhs.pullRequest.status == rhs.pullRequest.status && + lhs.pullRequest.approvalFrom.count == rhs.pullRequest.approvalFrom.count && + lhs.pullRequest.changesRequestedFrom.count == rhs.pullRequest.changesRequestedFrom.count + } + var body: some View { HStack(alignment: .top, spacing: 10) { VStack { diff --git a/PReek/Views/UtilityViews/ClippedMarkdownView.swift b/PReek/Views/UtilityViews/ClippedMarkdownView.swift index 8da52da..346f9ce 100644 --- a/PReek/Views/UtilityViews/ClippedMarkdownView.swift +++ b/PReek/Views/UtilityViews/ClippedMarkdownView.swift @@ -2,7 +2,7 @@ import MarkdownUI import SwiftUI private struct NoneImageProvider: ImageProvider { - public func makeImage(url _: URL?) -> some View { + func makeImage(url _: URL?) -> some View { Text("< Image >") } } @@ -12,7 +12,7 @@ private struct NoneInlineImageProvider: InlineImageProvider { case someError } - public func image(with _: URL, label _: String) async throws -> Image { + func image(with _: URL, label _: String) async throws -> Image { throw SomeError.someError } } diff --git a/PReek/Views/UtilityViews/TimeSensitiveText.swift b/PReek/Views/UtilityViews/TimeSensitiveText.swift index 3471bd5..f579a19 100644 --- a/PReek/Views/UtilityViews/TimeSensitiveText.swift +++ b/PReek/Views/UtilityViews/TimeSensitiveText.swift @@ -1,24 +1,34 @@ import SwiftUI +private class TimeSensitiveTextTimer: ObservableObject { + static let shared = TimeSensitiveTextTimer() + + @Published private(set) var tick = Date() + + private init() { + Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { _ in + self.tick = Date() + } + } +} + struct TimeSensitiveText: View { let getText: () -> String @State private var currentText: String + @ObservedObject private var timer = TimeSensitiveTextTimer.shared init(getText: @escaping () -> String) { self.getText = getText - currentText = getText() + _currentText = State(initialValue: getText()) } - private let timer = Timer.publish( - every: 30, - on: .main, - in: .common - ).autoconnect() - var body: some View { Text(currentText) - .onReceive(timer) { _ in - currentText = getText() + .onReceive(timer.$tick) { _ in + let newText = getText() + if newText != currentText { + currentText = newText + } } } } diff --git a/PReekTests/PullRequestUnreadTests.swift b/PReekTests/PullRequestUnreadTests.swift index da319d9..3c3f651 100644 --- a/PReekTests/PullRequestUnreadTests.swift +++ b/PReekTests/PullRequestUnreadTests.swift @@ -30,61 +30,61 @@ private func createPullRequest() -> PullRequest { struct PullRequestUnreadTests { @Test func noData() async throws { - var pullRequest = createPullRequest() - pullRequest.calculateUnread(viewer: viewer, readData: nil) + let pullRequest = createPullRequest() + let result = PullRequestUnreadCalculator.calculateUnread(for: pullRequest, viewer: viewer, readData: nil) - #expect(pullRequest.unread == true) - #expect(pullRequest.oldestUnreadEvent == nil) + #expect(result.unread == true) + #expect(result.oldestUnreadEvent == nil) } @Test func missingEventUnread() async throws { - var pullRequest = createPullRequest() - pullRequest.calculateUnread(viewer: viewer, readData: ReadData(date: toDate(minute: 15), eventId: "does-not-exist")) + let pullRequest = createPullRequest() + let result = PullRequestUnreadCalculator.calculateUnread(for: pullRequest, viewer: viewer, readData: ReadData(date: toDate(minute: 15), eventId: "does-not-exist")) - #expect(pullRequest.unread == true) - #expect(pullRequest.oldestUnreadEvent?.id == "2") + #expect(result.unread == true) + #expect(result.oldestUnreadEvent?.id == "2") } @Test func missingEventRead() async throws { - var pullRequest = createPullRequest() - pullRequest.calculateUnread(viewer: viewer, readData: ReadData(date: toDate(minute: 40), eventId: "does-not-exist")) + let pullRequest = createPullRequest() + let result = PullRequestUnreadCalculator.calculateUnread(for: pullRequest, viewer: viewer, readData: ReadData(date: toDate(minute: 40), eventId: "does-not-exist")) - #expect(pullRequest.unread == false) - #expect(pullRequest.oldestUnreadEvent == nil) + #expect(result.unread == false) + #expect(result.oldestUnreadEvent == nil) } @Test func missingEventReadIgnoringViewerEvent() async throws { - var pullRequest = createPullRequest() - pullRequest.calculateUnread(viewer: viewer, readData: ReadData(date: toDate(minute: 25), eventId: "does-not-exist")) + let pullRequest = createPullRequest() + let result = PullRequestUnreadCalculator.calculateUnread(for: pullRequest, viewer: viewer, readData: ReadData(date: toDate(minute: 25), eventId: "does-not-exist")) - #expect(pullRequest.unread == false) - #expect(pullRequest.oldestUnreadEvent == nil) + #expect(result.unread == false) + #expect(result.oldestUnreadEvent == nil) } @Test func eventUnread() async throws { - var pullRequest = createPullRequest() + let pullRequest = createPullRequest() // Date is ignored, demonstrated by date having a 'read' value - pullRequest.calculateUnread(viewer: viewer, readData: ReadData(date: toDate(minute: 40), eventId: "1")) + let result = PullRequestUnreadCalculator.calculateUnread(for: pullRequest, viewer: viewer, readData: ReadData(date: toDate(minute: 40), eventId: "1")) - #expect(pullRequest.unread == true) - #expect(pullRequest.oldestUnreadEvent?.id == "2") + #expect(result.unread == true) + #expect(result.oldestUnreadEvent?.id == "2") } @Test func eventRead() async throws { - var pullRequest = createPullRequest() + let pullRequest = createPullRequest() // Date is ignored, demonstrated by date having a 'unread' value - pullRequest.calculateUnread(viewer: viewer, readData: ReadData(date: toDate(minute: 0), eventId: "3")) + let result = PullRequestUnreadCalculator.calculateUnread(for: pullRequest, viewer: viewer, readData: ReadData(date: toDate(minute: 0), eventId: "3")) - #expect(pullRequest.unread == false) - #expect(pullRequest.oldestUnreadEvent == nil) + #expect(result.unread == false) + #expect(result.oldestUnreadEvent == nil) } @Test func eventReadIgnoringViewerEvent() async throws { - var pullRequest = createPullRequest() + let pullRequest = createPullRequest() // Date is ignored, demonstrated by date having a 'unread' value - pullRequest.calculateUnread(viewer: viewer, readData: ReadData(date: toDate(minute: 0), eventId: "2")) + let result = PullRequestUnreadCalculator.calculateUnread(for: pullRequest, viewer: viewer, readData: ReadData(date: toDate(minute: 0), eventId: "2")) - #expect(pullRequest.unread == false) - #expect(pullRequest.oldestUnreadEvent == nil) + #expect(result.unread == false) + #expect(result.oldestUnreadEvent == nil) } }