From 7e247f96524277f497c760f38a7b795a92c99411 Mon Sep 17 00:00:00 2001 From: Buqian Zheng Date: Wed, 25 Mar 2026 19:18:48 +0800 Subject: [PATCH] feat: multi-user monitoring, ignored repos/CI checks, floating window - Multi-user monitoring: add arbitrary GitHub usernames to monitor, segment control to switch between users, per-user status indicators - Per-user ignored repositories: filter out PRs from specified repos - Per-user ignored CI checks: glob patterns with per-repo scope, filtered checks excluded from overall status calculation - Floating window mode: detach from menu bar as a resizable, draggable floating window with frame persistence across close/reopen - Hide inactive PRs option: completely remove stale PRs from the list - GraphQL batch queries: single API call per user replaces N+1 REST calls - ShellExecutor concurrency: convert from actor to class for true parallel gh CLI execution across users - Cache persistence: restore PR data on startup, apply all filters - Inline editing for ignored repos/CI check rules in settings - Vertical action buttons with reserved space to prevent layout jumps - Expanded user tab clickable area Signed-off-by: Buqian Zheng --- MonitorLizard/Constants.swift | 8 +- MonitorLizard/Models/MonitoredUser.swift | 59 ++ MonitorLizard/Models/PullRequest.swift | 144 +++- .../MonitorLizard.xcodeproj/project.pbxproj | 16 + MonitorLizard/MonitorLizardApp.swift | 6 + MonitorLizard/Services/GitHubService.swift | 393 ++++++----- .../Services/MonitoredUsersService.swift | 90 +++ MonitorLizard/Services/PRCacheService.swift | 63 ++ MonitorLizard/Services/RefreshLogger.swift | 35 + MonitorLizard/Services/ShellExecutor.swift | 98 ++- MonitorLizard/Services/WindowManager.swift | 121 +++- .../ViewModels/PRMonitorViewModel.swift | 658 +++++++++++++----- MonitorLizard/Views/MenuBarView.swift | 213 +++--- MonitorLizard/Views/PRRowView.swift | 464 +++++++++--- MonitorLizard/Views/SettingsView.swift | 619 +++++++++++++++- 15 files changed, 2422 insertions(+), 565 deletions(-) create mode 100644 MonitorLizard/Models/MonitoredUser.swift create mode 100644 MonitorLizard/Services/MonitoredUsersService.swift create mode 100644 MonitorLizard/Services/PRCacheService.swift create mode 100644 MonitorLizard/Services/RefreshLogger.swift diff --git a/MonitorLizard/Constants.swift b/MonitorLizard/Constants.swift index aa4e678..616df30 100644 --- a/MonitorLizard/Constants.swift +++ b/MonitorLizard/Constants.swift @@ -16,8 +16,12 @@ enum Constants { // UI constants static let menuMaxHeightMultiplier = 0.7 - static let settingsWindowWidth = 450.0 - static let settingsWindowHeight = 500.0 + static let settingsWindowWidth = 580.0 + static let settingsWindowHeight = 550.0 + + // Quiet hours defaults + static let defaultQuietHoursStart = 20 // 20:00 + static let defaultQuietHoursEnd = 9 // 09:00 // Voice announcement static let defaultVoiceAnnouncementText = "Build ready for Q A" diff --git a/MonitorLizard/Models/MonitoredUser.swift b/MonitorLizard/Models/MonitoredUser.swift new file mode 100644 index 0000000..b389b0a --- /dev/null +++ b/MonitorLizard/Models/MonitoredUser.swift @@ -0,0 +1,59 @@ +import Foundation + +struct IgnoredCheckRule: Codable, Equatable, Identifiable { + var id: UUID = UUID() + var pattern: String // glob: "codecov/*", "DCO" + var repository: String // "owner/repo" or "*" for all repos + + func matches(checkName: String, repo: String) -> Bool { + let repoMatches = repository == "*" || repository == repo + guard repoMatches else { return false } + return globMatch(pattern: pattern, string: checkName) + } +} + +struct MonitoredUser: Codable, Identifiable, Equatable { + var id: UUID + var username: String // "@me" or any GitHub username + var displayName: String? // optional label for segment control + var ignoredRepos: [String] // ["owner/repo", ...] + var ignoredChecks: [IgnoredCheckRule] + + var label: String { + displayName ?? (username == "@me" ? "@me" : username) + } + + var isMe: Bool { username == "@me" } + + static func defaultMe() -> MonitoredUser { + MonitoredUser( + id: UUID(), + username: "@me", + displayName: nil, + ignoredRepos: [], + ignoredChecks: [] + ) + } +} + +/// Simple glob matching supporting only `*` wildcard. +/// `*` matches any sequence of characters. +/// Example: "codecov/*" matches "codecov/patch", "codecov/project". +func globMatch(pattern: String, string: String) -> Bool { + let parts = pattern.split(separator: "*", omittingEmptySubsequences: false).map(String.init) + if parts.count == 1 { + return pattern == string + } + + var remaining = string[...] + for (i, part) in parts.enumerated() { + if part.isEmpty { continue } + guard let range = remaining.range(of: part) else { return false } + if i == 0 && range.lowerBound != remaining.startIndex { return false } + remaining = remaining[range.upperBound...] + } + if let last = parts.last, !last.isEmpty { + return string.hasSuffix(last) + } + return true +} diff --git a/MonitorLizard/Models/PullRequest.swift b/MonitorLizard/Models/PullRequest.swift index 048cfc6..9520e58 100644 --- a/MonitorLizard/Models/PullRequest.swift +++ b/MonitorLizard/Models/PullRequest.swift @@ -54,9 +54,24 @@ enum PRType: String, Codable, Hashable { func displayTitle(count: Int) -> String { pluralizes && count != 1 ? sectionTitle + "s" : sectionTitle } + + func displayTitle(count: Int, username: String?) -> String { + switch self { + case .reviewing: + return "Awaiting My Review" + case .other: + return count != 1 ? "Other PRs" : "Other PR" + case .authored: + if let username, username != "@me" { + let base = "\(username)'s PR" + return count != 1 ? base + "s" : base + } + return count != 1 ? "My PRs" : "My PR" + } + } } -struct PullRequest: Identifiable, Hashable { +struct PullRequest: Identifiable, Hashable, Codable { let number: Int let title: String let repository: RepositoryInfo @@ -69,10 +84,11 @@ struct PullRequest: Identifiable, Hashable { let labels: [Label] let type: PRType let isDraft: Bool - let statusChecks: [StatusCheck] + var statusChecks: [StatusCheck] var reviewDecision: ReviewDecision? let host: String // GitHub host (e.g. "github.com" or enterprise hostname) var customName: String? // nil = use GitHub title + var ignoredCheckCount: Int = 0 // Number of ignored failing checks var displayTitle: String { customName ?? title } @@ -84,16 +100,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 @@ -176,6 +192,11 @@ struct GHPRDetailResponse: Codable { struct ReviewRequest: Codable { let login: String? // nil for team review requests + + // Support both REST and GraphQL formats + enum CodingKeys: String, CodingKey { + case login + } } struct StatusCheck: Codable { @@ -193,3 +214,116 @@ struct GHPRDetailResponse: Codable { } } } + +// MARK: - GraphQL Response Models + +struct GraphQLSearchResponse: Codable { + let data: GraphQLData? + let errors: [GraphQLError]? +} + +struct GraphQLError: Codable { + let message: String +} + +struct GraphQLData: Codable { + let search: GraphQLSearch +} + +struct GraphQLSearch: Codable { + let nodes: [GraphQLPRNode] +} + +struct GraphQLPRNode: Codable { + let number: Int + let title: String + let url: String + let isDraft: Bool + let headRefName: String + let updatedAt: String + let mergeable: String? + let reviewDecision: String? + let author: GraphQLAuthor? + let repository: GraphQLRepository + let labels: GraphQLLabels? + let commits: GraphQLCommits? + let latestReviews: GraphQLReviews? + let reviewRequests: GraphQLReviewRequests? +} + +struct GraphQLAuthor: Codable { + let login: String +} + +struct GraphQLRepository: Codable { + let name: String + let nameWithOwner: String +} + +struct GraphQLLabels: Codable { + let nodes: [GraphQLLabel]? +} + +struct GraphQLLabel: Codable { + let id: String + let name: String + let color: String +} + +struct GraphQLCommits: Codable { + let nodes: [GraphQLCommitNode]? +} + +struct GraphQLCommitNode: Codable { + let commit: GraphQLCommit +} + +struct GraphQLCommit: Codable { + let statusCheckRollup: GraphQLStatusCheckRollup? +} + +struct GraphQLStatusCheckRollup: Codable { + let contexts: GraphQLContexts +} + +struct GraphQLContexts: Codable { + let nodes: [GraphQLCheckContext] +} + +struct GraphQLCheckContext: Codable { + let __typename: String + // CheckRun fields + let name: String? + let status: String? + let conclusion: String? + let detailsUrl: String? + // StatusContext fields + let context: String? + let state: String? + let targetUrl: String? + + private enum CodingKeys: String, CodingKey { + case __typename, name, status, conclusion, detailsUrl, context, state, targetUrl + } +} + +struct GraphQLReviews: Codable { + let nodes: [GraphQLReviewNode]? +} + +struct GraphQLReviewNode: Codable { + let author: GraphQLAuthor? + let state: String +} + +struct GraphQLReviewRequests: Codable { + let nodes: [GraphQLReviewRequestNode]? +} + +struct GraphQLReviewRequestNode: Codable { + let requestedReviewer: GraphQLRequestedReviewer? +} + +struct GraphQLRequestedReviewer: Codable { + let login: String? +} diff --git a/MonitorLizard/MonitorLizard.xcodeproj/project.pbxproj b/MonitorLizard/MonitorLizard.xcodeproj/project.pbxproj index d168394..53eb6b9 100644 --- a/MonitorLizard/MonitorLizard.xcodeproj/project.pbxproj +++ b/MonitorLizard/MonitorLizard.xcodeproj/project.pbxproj @@ -31,6 +31,10 @@ AA0001112F103E3200F0ABCD /* OtherPRsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0002222F103E3200F0ABCD /* OtherPRsService.swift */; }; AA0003332F103E3200F0ABCD /* AddPRView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0004442F103E3200F0ABCD /* AddPRView.swift */; }; BB0001112F103E3200F0ABCD /* CustomNamesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0002222F103E3200F0ABCD /* CustomNamesService.swift */; }; + CC0001112F103E3200F0ABCD /* MonitoredUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0002222F103E3200F0ABCD /* MonitoredUser.swift */; }; + CC0003332F103E3200F0ABCD /* MonitoredUsersService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0004442F103E3200F0ABCD /* MonitoredUsersService.swift */; }; + DD0001112F103E3200F0ABCD /* PRCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0002222F103E3200F0ABCD /* PRCacheService.swift */; }; + EE0001112F103E3200F0ABCD /* RefreshLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0002222F103E3200F0ABCD /* RefreshLogger.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -78,6 +82,10 @@ AA0002222F103E3200F0ABCD /* OtherPRsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherPRsService.swift; sourceTree = ""; }; AA0004442F103E3200F0ABCD /* AddPRView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPRView.swift; sourceTree = ""; }; BB0002222F103E3200F0ABCD /* CustomNamesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomNamesService.swift; sourceTree = ""; }; + CC0002222F103E3200F0ABCD /* MonitoredUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitoredUser.swift; sourceTree = ""; }; + CC0004442F103E3200F0ABCD /* MonitoredUsersService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitoredUsersService.swift; sourceTree = ""; }; + DD0002222F103E3200F0ABCD /* PRCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRCacheService.swift; sourceTree = ""; }; + EE0002222F103E3200F0ABCD /* RefreshLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshLogger.swift; sourceTree = ""; }; F1EE91F749D44258AE659F6C /* WindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -141,6 +149,7 @@ 329D9EDE2F103E3200F0E6EA /* BuildStatus.swift */, 329D9EDF2F103E3200F0E6EA /* PullRequest.swift */, 32DEMO9E2F103E3200F0E6EA /* DemoData.swift */, + CC0002222F103E3200F0ABCD /* MonitoredUser.swift */, ); path = Models; sourceTree = ""; @@ -164,6 +173,9 @@ 324376102F5DB275009B7E2D /* UpdateService.swift */, AA0002222F103E3200F0ABCD /* OtherPRsService.swift */, BB0002222F103E3200F0ABCD /* CustomNamesService.swift */, + CC0004442F103E3200F0ABCD /* MonitoredUsersService.swift */, + DD0002222F103E3200F0ABCD /* PRCacheService.swift */, + EE0002222F103E3200F0ABCD /* RefreshLogger.swift */, ); path = Services; sourceTree = ""; @@ -357,6 +369,10 @@ AA0001112F103E3200F0ABCD /* OtherPRsService.swift in Sources */, BB0001112F103E3200F0ABCD /* CustomNamesService.swift in Sources */, AA0003332F103E3200F0ABCD /* AddPRView.swift in Sources */, + CC0001112F103E3200F0ABCD /* MonitoredUser.swift in Sources */, + CC0003332F103E3200F0ABCD /* MonitoredUsersService.swift in Sources */, + DD0001112F103E3200F0ABCD /* PRCacheService.swift in Sources */, + EE0001112F103E3200F0ABCD /* RefreshLogger.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MonitorLizard/MonitorLizardApp.swift b/MonitorLizard/MonitorLizardApp.swift index f586be0..868614f 100644 --- a/MonitorLizard/MonitorLizardApp.swift +++ b/MonitorLizard/MonitorLizardApp.swift @@ -6,6 +6,7 @@ struct MonitorLizardApp: App { let isDemoMode = CommandLine.arguments.contains("--demo-mode") return PRMonitorViewModel(isDemoMode: isDemoMode) }() + @State private var didRestoreFloatingWindowAtLaunch = false private let updateService = UpdateService.shared var body: some Scene { @@ -14,6 +15,11 @@ struct MonitorLizardApp: App { .environmentObject(viewModel) } label: { MenuBarLabel(showWarningIcon: viewModel.showWarningIcon) + .task { + guard !didRestoreFloatingWindowAtLaunch else { return } + didRestoreFloatingWindowAtLaunch = true + WindowManager.shared.restoreFloatingWindowIfNeeded(viewModel: viewModel) + } } .menuBarExtraStyle(.window) diff --git a/MonitorLizard/Services/GitHubService.swift b/MonitorLizard/Services/GitHubService.swift index f79c328..3206afe 100644 --- a/MonitorLizard/Services/GitHubService.swift +++ b/MonitorLizard/Services/GitHubService.swift @@ -72,12 +72,24 @@ class GitHubService: ObservableObject { } func fetchAllOpenPRs(enableInactiveDetection: Bool, inactiveThresholdDays: Int, isDemoMode: Bool = false) async throws -> PRFetchResult { - // Return demo data if in demo mode + return try await fetchPRsForUser( + username: "@me", + enableInactiveDetection: enableInactiveDetection, + inactiveThresholdDays: inactiveThresholdDays + ) + } + + /// Fetches PRs for a specific user. For "@me", fetches both authored and + /// review-requested PRs. For other usernames, only authored PRs. + func fetchPRsForUser( + username: String, + enableInactiveDetection: Bool, + inactiveThresholdDays: Int + ) async throws -> PRFetchResult { if isDemoMode { return PRFetchResult(pullRequests: DemoData.samplePullRequests, isPartial: false) } - // Detect all authenticated GitHub hosts (github.com + any enterprise instances) let hosts = try await shellExecutor.getAuthenticatedHosts() var allAuthored: [PullRequest] = [] @@ -86,12 +98,12 @@ class GitHubService: ObservableObject { var allFailed = true for host in hosts { - // Fetch both authored and review PRs in parallel with independent error handling - async let authoredTask = fetchAuthoredPRsSafely(enableInactiveDetection: enableInactiveDetection, inactiveThresholdDays: inactiveThresholdDays, host: host) - async let reviewTask = fetchReviewPRsSafely(enableInactiveDetection: enableInactiveDetection, inactiveThresholdDays: inactiveThresholdDays, host: host) - - let authoredResult = await authoredTask - let reviewResult = await reviewTask + let authoredResult = await fetchAuthoredPRsForUserSafely( + username: username, + enableInactiveDetection: enableInactiveDetection, + inactiveThresholdDays: inactiveThresholdDays, + host: host + ) if let authored = authoredResult.success { allAuthored.append(contentsOf: authored) @@ -100,238 +112,263 @@ class GitHubService: ObservableObject { anyPartial = true } - if let review = reviewResult.success { - allReview.append(contentsOf: review) - allFailed = false - } else { - anyPartial = true + // Review PRs only for @me + if username == "@me" { + let reviewResult = await fetchReviewPRsSafely( + enableInactiveDetection: enableInactiveDetection, + inactiveThresholdDays: inactiveThresholdDays, + host: host + ) + if let review = reviewResult.success { + allReview.append(contentsOf: review) + allFailed = false + } else { + anyPartial = true + } } } - // If all fetches across all hosts failed, throw an error if allFailed { throw GitHubError.networkError } - return PRFetchResult(pullRequests: allReview + allAuthored, isPartial: anyPartial) // Review PRs first to prioritize unblocking teammates + return PRFetchResult(pullRequests: allReview + allAuthored, isPartial: anyPartial) } - private func fetchAuthoredPRsSafely(enableInactiveDetection: Bool, inactiveThresholdDays: Int, host: String) async -> Result<[PullRequest], Error> { + private func fetchReviewPRsSafely(enableInactiveDetection: Bool, inactiveThresholdDays: Int, host: String) async -> Result<[PullRequest], Error> { do { - let prs = try await fetchAuthoredPRs(enableInactiveDetection: enableInactiveDetection, inactiveThresholdDays: inactiveThresholdDays, host: host) + let prs = try await fetchReviewPRs(enableInactiveDetection: enableInactiveDetection, inactiveThresholdDays: inactiveThresholdDays, host: host) return .success(prs) } catch { - print("Error fetching authored PRs from \(host): \(error)") + print("Error fetching review PRs from \(host): \(error)") return .failure(error) } } - private func fetchReviewPRsSafely(enableInactiveDetection: Bool, inactiveThresholdDays: Int, host: String) async -> Result<[PullRequest], Error> { + private func fetchAuthoredPRsForUserSafely( + username: String, + enableInactiveDetection: Bool, + inactiveThresholdDays: Int, + host: String + ) async -> Result<[PullRequest], Error> { do { - let prs = try await fetchReviewPRs(enableInactiveDetection: enableInactiveDetection, inactiveThresholdDays: inactiveThresholdDays, host: host) + let prs = try await fetchAuthoredPRsForUser( + username: username, + enableInactiveDetection: enableInactiveDetection, + inactiveThresholdDays: inactiveThresholdDays, + host: host + ) return .success(prs) } catch { - print("Error fetching review PRs from \(host): \(error)") + print("Error fetching authored PRs for \(username) from \(host): \(error)") return .failure(error) } } - private func fetchAuthoredPRs(enableInactiveDetection: Bool, inactiveThresholdDays: Int, host: String) async throws -> [PullRequest] { - // Fetch all open PRs authored by the current user, excluding archived repositories + /// Fetches authored PRs for a user via a single GraphQL query. + /// This replaces the N+1 pattern (1 search + N status fetches) with 1 API call. + private func fetchAuthoredPRsForUser( + username: String, + enableInactiveDetection: Bool, + inactiveThresholdDays: Int, + host: String + ) async throws -> [PullRequest] { + let query = buildGraphQLQuery(author: username) + let logger = await RefreshLogger.shared + let json = try await shellExecutor.execute( command: "gh", - arguments: [ - "search", "prs", - "--author=@me", - "--state=open", - "--archived=false", - "--json", "number,title,repository,url,author,updatedAt,labels,isDraft", - "--limit", "100" - ], + arguments: ["api", "graphql", "-f", "query=\(query)"], + timeout: 60, host: host ) - // Parse the JSON response guard let jsonData = json.data(using: .utf8) else { throw GitHubError.invalidResponse } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let dateString = try container.decode(String.self) - - // Try different date formats - let formatters: [ISO8601DateFormatter] = [ - { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return f - }(), - { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime] - return f - }() - ] - - for formatter in formatters { - if let date = formatter.date(from: dateString) { - return date - } - } + let response = try JSONDecoder().decode(GraphQLSearchResponse.self, from: jsonData) - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Cannot decode date string \(dateString)" - ) + if let errors = response.errors, !errors.isEmpty { + let msg = errors.map(\.message).joined(separator: "; ") + await logger.log(" \(username)@\(host): GraphQL errors: \(msg)") + throw GitHubError.invalidResponse } - let searchResults = try decoder.decode([GHPRSearchResponse].self, from: jsonData) - - // Convert to PullRequest objects and fetch status for each - var pullRequests: [PullRequest] = [] - - for result in searchResults { - let updatedAt = try parseDate(result.updatedAt) + let nodes = response.data?.search.nodes ?? [] + await logger.log(" \(username)@\(host): GraphQL returned \(nodes.count) PRs (1 API call)") - // Fetch detailed PR info with status - let statusInfo = try await fetchPRStatus( - owner: extractOwner(from: result.repository.nameWithOwner), - repo: extractRepo(from: result.repository.nameWithOwner), - number: result.number, - updatedAt: updatedAt, + return nodes.compactMap { node in + graphQLNodeToPullRequest( + node: node, type: .authored, host: host, enableInactiveDetection: enableInactiveDetection, - inactiveThresholdDays: inactiveThresholdDays, - host: host + inactiveThresholdDays: inactiveThresholdDays ) + } + } - let pr = PullRequest( - number: result.number, - title: result.title, - repository: PullRequest.RepositoryInfo( - name: result.repository.name, - nameWithOwner: result.repository.nameWithOwner - ), - url: result.url, - author: PullRequest.Author(login: result.author.login), - headRefName: statusInfo.headRefName, - updatedAt: updatedAt, - buildStatus: statusInfo.status, - isWatched: false, - labels: result.labels.map { label in - PullRequest.Label(id: label.id, name: label.name, color: label.color) - }, - type: .authored, - isDraft: result.isDraft, - statusChecks: statusInfo.statusChecks, - reviewDecision: statusInfo.reviewDecision, - host: host + private func buildGraphQLQuery(author: String, reviewRequested: String? = nil) -> String { + let searchQualifier: String + if let reviewer = reviewRequested { + searchQualifier = "review-requested:\(reviewer) is:pr is:open archived:false" + } else { + searchQualifier = "author:\(author) is:pr is:open archived:false" + } + + return """ + { + search(query: "\(searchQualifier)", type: ISSUE, first: 100) { + nodes { + ... on PullRequest { + number + title + url + isDraft + headRefName + updatedAt + mergeable + reviewDecision + author { login } + repository { name nameWithOwner } + labels(first: 20) { nodes { id name color } } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { + name + status + conclusion + detailsUrl + } + ... on StatusContext { + context + state + targetUrl + } + } + } + } + } + } + } + latestReviews(first: 20) { nodes { author { login } state } } + reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } } } } + } + } + } + } + """ + } + + private func graphQLNodeToPullRequest( + node: GraphQLPRNode, + type: PRType, + host: String, + enableInactiveDetection: Bool, + inactiveThresholdDays: Int + ) -> PullRequest? { + guard let updatedAt = try? parseDate(node.updatedAt) else { return nil } + + // Convert GraphQL status check contexts to our GHPRDetailResponse.StatusCheck format + let rawChecks: [GHPRDetailResponse.StatusCheck] = node.commits?.nodes?.first?.commit.statusCheckRollup?.contexts.nodes.map { ctx in + GHPRDetailResponse.StatusCheck( + name: ctx.name, + context: ctx.context, + status: ctx.status, + state: ctx.state, + conclusion: ctx.conclusion, + __typename: ctx.__typename, + detailsUrl: ctx.detailsUrl, + targetUrl: ctx.targetUrl ) + } ?? [] + + let statusChecks = parseStatusChecks(from: rawChecks.isEmpty ? nil : rawChecks) + let status = parseOverallStatus( + from: rawChecks.isEmpty ? nil : rawChecks, + mergeable: node.mergeable, + mergeStateStatus: nil, + updatedAt: updatedAt, + enableInactiveDetection: enableInactiveDetection, + inactiveThresholdDays: inactiveThresholdDays + ) - pullRequests.append(pr) + let latestReviews = node.latestReviews?.nodes?.map { + GHPRDetailResponse.Review( + author: $0.author.map { GHPRDetailResponse.Review.ReviewAuthor(login: $0.login) }, + state: $0.state + ) + } + let reviewRequests = node.reviewRequests?.nodes?.map { + GHPRDetailResponse.ReviewRequest(login: $0.requestedReviewer?.login) } - return pullRequests + let reviewDecision = Self.resolveReviewDecision( + rawValue: node.reviewDecision, + latestReviews: latestReviews, + reviewRequests: reviewRequests + ) + + return PullRequest( + number: node.number, + title: node.title, + repository: PullRequest.RepositoryInfo( + name: node.repository.name, + nameWithOwner: node.repository.nameWithOwner + ), + url: node.url, + author: PullRequest.Author(login: node.author?.login ?? "unknown"), + headRefName: node.headRefName, + updatedAt: updatedAt, + buildStatus: status, + isWatched: false, + labels: node.labels?.nodes?.map { PullRequest.Label(id: $0.id, name: $0.name, color: $0.color) } ?? [], + type: type, + isDraft: node.isDraft, + statusChecks: statusChecks, + reviewDecision: reviewDecision, + host: host + ) } + /// Fetches review-requested PRs via a single GraphQL query. private func fetchReviewPRs(enableInactiveDetection: Bool, inactiveThresholdDays: Int, host: String) async throws -> [PullRequest] { - // Fetch all open PRs where the current user is a requested reviewer, excluding archived repositories + let query = buildGraphQLQuery(author: "@me", reviewRequested: "@me") + let logger = await RefreshLogger.shared + let json = try await shellExecutor.execute( command: "gh", - arguments: [ - "search", "prs", - "--review-requested=@me", - "--state=open", - "--archived=false", - "--json", "number,title,repository,url,author,updatedAt,labels,isDraft", - "--limit", "100" - ], + arguments: ["api", "graphql", "-f", "query=\(query)"], + timeout: 60, host: host ) - // Parse the JSON response guard let jsonData = json.data(using: .utf8) else { throw GitHubError.invalidResponse } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let dateString = try container.decode(String.self) - - // Try different date formats - let formatters: [ISO8601DateFormatter] = [ - { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return f - }(), - { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime] - return f - }() - ] - - for formatter in formatters { - if let date = formatter.date(from: dateString) { - return date - } - } + let response = try JSONDecoder().decode(GraphQLSearchResponse.self, from: jsonData) - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Cannot decode date string \(dateString)" - ) + if let errors = response.errors, !errors.isEmpty { + let msg = errors.map(\.message).joined(separator: "; ") + await logger.log(" review@\(host): GraphQL errors: \(msg)") + throw GitHubError.invalidResponse } - let searchResults = try decoder.decode([GHPRSearchResponse].self, from: jsonData) + let nodes = response.data?.search.nodes ?? [] + await logger.log(" review@\(host): GraphQL returned \(nodes.count) review PRs (1 API call)") - // Convert to PullRequest objects and fetch status for each - var pullRequests: [PullRequest] = [] - - for result in searchResults { - let updatedAt = try parseDate(result.updatedAt) - - // Fetch detailed PR info with status - let statusInfo = try await fetchPRStatus( - owner: extractOwner(from: result.repository.nameWithOwner), - repo: extractRepo(from: result.repository.nameWithOwner), - number: result.number, - updatedAt: updatedAt, + return nodes.compactMap { node in + graphQLNodeToPullRequest( + node: node, type: .reviewing, host: host, enableInactiveDetection: enableInactiveDetection, - inactiveThresholdDays: inactiveThresholdDays, - host: host - ) - - let pr = PullRequest( - number: result.number, - title: result.title, - repository: PullRequest.RepositoryInfo( - name: result.repository.name, - nameWithOwner: result.repository.nameWithOwner - ), - url: result.url, - author: PullRequest.Author(login: result.author.login), - headRefName: statusInfo.headRefName, - updatedAt: updatedAt, - buildStatus: statusInfo.status, - isWatched: false, - labels: result.labels.map { label in - PullRequest.Label(id: label.id, name: label.name, color: label.color) - }, - type: .reviewing, - isDraft: result.isDraft, - statusChecks: statusInfo.statusChecks, - reviewDecision: statusInfo.reviewDecision, - host: host + inactiveThresholdDays: inactiveThresholdDays ) - - pullRequests.append(pr) } - - return pullRequests } func fetchPRStatus(owner: String, repo: String, number: Int, updatedAt: Date, enableInactiveDetection: Bool, inactiveThresholdDays: Int, host: String = "github.com") async throws -> (status: BuildStatus, headRefName: String, statusChecks: [StatusCheck], reviewDecision: ReviewDecision?) { diff --git a/MonitorLizard/Services/MonitoredUsersService.swift b/MonitorLizard/Services/MonitoredUsersService.swift new file mode 100644 index 0000000..4faded1 --- /dev/null +++ b/MonitorLizard/Services/MonitoredUsersService.swift @@ -0,0 +1,90 @@ +import Foundation +import Combine + +class MonitoredUsersService: ObservableObject { + static let shared = MonitoredUsersService() + + private let defaults: UserDefaults + private let usersKey = "monitoredUsers" + private let selectedKey = "selectedUserId" + + @Published var users: [MonitoredUser] = [] + @Published var selectedUserId: UUID? + + var selectedUser: MonitoredUser? { + users.first { $0.id == selectedUserId } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + loadUsers() + } + + private func loadUsers() { + if let data = defaults.data(forKey: usersKey), + let decoded = try? JSONDecoder().decode([MonitoredUser].self, from: data), + !decoded.isEmpty { + users = decoded + } else { + users = [.defaultMe()] + saveUsers() + } + + if let idString = defaults.string(forKey: selectedKey), + let id = UUID(uuidString: idString), + users.contains(where: { $0.id == id }) { + selectedUserId = id + } else { + selectedUserId = users.first?.id + saveSelectedId() + } + } + + func addUser(username: String, displayName: String?) { + let user = MonitoredUser( + id: UUID(), + username: username, + displayName: displayName?.isEmpty == true ? nil : displayName, + ignoredRepos: [], + ignoredChecks: [] + ) + users.append(user) + saveUsers() + } + + func removeUser(id: UUID) { + guard let user = users.first(where: { $0.id == id }), !user.isMe else { return } + users.removeAll { $0.id == id } + if selectedUserId == id { + selectedUserId = users.first?.id + saveSelectedId() + } + saveUsers() + } + + func updateUser(_ user: MonitoredUser) { + guard let index = users.firstIndex(where: { $0.id == user.id }) else { return } + users[index] = user + saveUsers() + } + + func notifyConfigurationChanged() { + users = users + } + + func selectUser(id: UUID) { + guard users.contains(where: { $0.id == id }) else { return } + selectedUserId = id + saveSelectedId() + } + + private func saveUsers() { + if let data = try? JSONEncoder().encode(users) { + defaults.set(data, forKey: usersKey) + } + } + + private func saveSelectedId() { + defaults.set(selectedUserId?.uuidString, forKey: selectedKey) + } +} diff --git a/MonitorLizard/Services/PRCacheService.swift b/MonitorLizard/Services/PRCacheService.swift new file mode 100644 index 0000000..21b7b30 --- /dev/null +++ b/MonitorLizard/Services/PRCacheService.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Persists per-user PR data to disk so the app can restore state on restart +/// without waiting for a fresh GitHub fetch. +class PRCacheService { + static let shared = PRCacheService() + + private let defaults: UserDefaults + private let cacheKey = "prCacheData" + private let otherPRsKey = "otherPRsCacheData" + /// Hash of the last written data to skip redundant writes. + private var lastCacheHash: Int = 0 + private var lastOtherHash: Int = 0 + + struct CachedUserData: Codable { + var rawPRs: [PullRequest] + var unsortedPRs: [PullRequest] + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func save(perUserCache: [UUID: (raw: [PullRequest], filtered: [PullRequest])], otherPRs: [PullRequest]) { + let codableCache = perUserCache.reduce(into: [String: CachedUserData]()) { dict, entry in + dict[entry.key.uuidString] = CachedUserData(rawPRs: entry.value.raw, unsortedPRs: entry.value.filtered) + } + if let data = try? JSONEncoder().encode(codableCache) { + let hash = data.hashValue + if hash != lastCacheHash { + lastCacheHash = hash + defaults.set(data, forKey: cacheKey) + } + } + if let data = try? JSONEncoder().encode(otherPRs) { + let hash = data.hashValue + if hash != lastOtherHash { + lastOtherHash = hash + defaults.set(data, forKey: otherPRsKey) + } + } + } + + func loadPerUserCache() -> [UUID: CachedUserData] { + guard let data = defaults.data(forKey: cacheKey), + let decoded = try? JSONDecoder().decode([String: CachedUserData].self, from: data) else { + return [:] + } + return decoded.reduce(into: [UUID: CachedUserData]()) { dict, entry in + if let uuid = UUID(uuidString: entry.key) { + dict[uuid] = entry.value + } + } + } + + func loadOtherPRs() -> [PullRequest] { + guard let data = defaults.data(forKey: otherPRsKey), + let decoded = try? JSONDecoder().decode([PullRequest].self, from: data) else { + return [] + } + return decoded + } +} diff --git a/MonitorLizard/Services/RefreshLogger.swift b/MonitorLizard/Services/RefreshLogger.swift new file mode 100644 index 0000000..f038ff2 --- /dev/null +++ b/MonitorLizard/Services/RefreshLogger.swift @@ -0,0 +1,35 @@ +import Foundation +import Combine + +@MainActor +class RefreshLogger: ObservableObject { + static let shared = RefreshLogger() + + @Published var logs: String = "" + private var lines: [String] = [] + private let maxLines = 200 + + private init() {} + + func log(_ message: String) { + let timestamp = Self.formatter.string(from: Date()) + let line = "[\(timestamp)] \(message)" + print(line) + lines.append(line) + if lines.count > maxLines { + lines.removeFirst(lines.count - maxLines) + } + logs = lines.joined(separator: "\n") + } + + func clear() { + lines.removeAll() + logs = "" + } + + private static let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss.SSS" + return f + }() +} diff --git a/MonitorLizard/Services/ShellExecutor.swift b/MonitorLizard/Services/ShellExecutor.swift index 58e6ecd..92dac1b 100644 --- a/MonitorLizard/Services/ShellExecutor.swift +++ b/MonitorLizard/Services/ShellExecutor.swift @@ -20,7 +20,10 @@ enum ShellError: Error { } } -actor ShellExecutor { +/// ShellExecutor runs shell commands. Each invocation creates its own +/// `Process`, so concurrent calls are safe. Marked as `final class` +/// (not `actor`) to allow true parallelism in task groups. +final class ShellExecutor: Sendable { func execute(command: String, arguments: [String] = [], timeout: TimeInterval = 30, host: String? = nil) async throws -> String { let process = Process() @@ -47,34 +50,76 @@ actor ShellExecutor { process.environment = environment - // Set up pipes for output and error + // Set up pipes for output and error. + // Collect data as it arrives via readabilityHandler to avoid + // deadlock when output exceeds the pipe buffer (~64KB). let outputPipe = Pipe() let errorPipe = Pipe() process.standardOutput = outputPipe process.standardError = errorPipe - // Launch the process - do { - try process.run() - } catch { - throw ShellError.commandNotFound + let outputAccumulator = PipeAccumulator() + let errorAccumulator = PipeAccumulator() + outputPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + if !data.isEmpty { outputAccumulator.append(data) } } + errorPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + if !data.isEmpty { errorAccumulator.append(data) } + } + + // Wait for completion without blocking the cooperative thread pool. + // terminationHandler must be set BEFORE run() to avoid a race + // where the process finishes before the handler is installed. + let timedOut = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + var resumed = false + let lock = NSLock() + + process.terminationHandler = { _ in + lock.lock() + guard !resumed else { lock.unlock(); return } + resumed = true + lock.unlock() + continuation.resume(returning: false) + } + + do { + try process.run() + } catch { + // Clear the handler so we don't double-resume + process.terminationHandler = nil + lock.lock() + resumed = true + lock.unlock() + continuation.resume(throwing: ShellError.commandNotFound) + return + } - // Wait for completion with timeout - let timeoutTask = Task { - try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) - if process.isRunning { + // Timeout: terminate the process if it hasn't finished in time + Task { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + lock.lock() + guard !resumed else { lock.unlock(); return } + resumed = true + lock.unlock() process.terminate() - throw ShellError.executionFailed("Command timed out after \(timeout) seconds") + continuation.resume(returning: true) } } - process.waitUntilExit() - timeoutTask.cancel() + // Stop reading handlers and drain remaining data + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + outputAccumulator.append(outputPipe.fileHandleForReading.readDataToEndOfFile()) + errorAccumulator.append(errorPipe.fileHandleForReading.readDataToEndOfFile()) - // Read output - let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + if timedOut { + throw ShellError.executionFailed("Command timed out after \(Int(timeout)) seconds") + } + + let outputData = outputAccumulator.data + let errorData = errorAccumulator.data // Check exit status guard process.terminationStatus == 0 else { @@ -195,3 +240,22 @@ actor ShellExecutor { } } } + +/// Thread-safe accumulator for pipe data collected via readabilityHandler. +private final class PipeAccumulator: @unchecked Sendable { + private let lock = NSLock() + private var buffer = Data() + + func append(_ data: Data) { + guard !data.isEmpty else { return } + lock.lock() + buffer.append(data) + lock.unlock() + } + + var data: Data { + lock.lock() + defer { lock.unlock() } + return buffer + } +} diff --git a/MonitorLizard/Services/WindowManager.swift b/MonitorLizard/Services/WindowManager.swift index 8d74ac6..0b720c6 100644 --- a/MonitorLizard/Services/WindowManager.swift +++ b/MonitorLizard/Services/WindowManager.swift @@ -1,16 +1,22 @@ import SwiftUI import AppKit +private let kFloatingFrameKey = "floatingWindowFrame" + @MainActor class WindowManager { static let shared = WindowManager() private var settingsWindow: NSWindow? + private var floatingWindow: NSWindow? + private(set) var isFloatingMode = false + private let floatingWindowDelegate = FloatingWindowDelegate() private init() {} + // MARK: - Add PR + func showAddPR(viewModel: PRMonitorViewModel) { - // Close the menu bar extra by ordering out all panels NSApp.windows.forEach { window in if window is NSPanel { window.orderOut(nil) @@ -31,8 +37,9 @@ class WindowManager { NSApp.activate(ignoringOtherApps: true) } + // MARK: - Settings + func showSettings() { - // Close the menu bar extra by ordering out all panels NSApp.windows.forEach { window in if window is NSPanel { window.orderOut(nil) @@ -61,4 +68,114 @@ class WindowManager { window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } + + // MARK: - Floating Window + + private func saveFloatingFrame() { + guard let window = floatingWindow else { return } + let frameString = NSStringFromRect(window.frame) + UserDefaults.standard.set(frameString, forKey: kFloatingFrameKey) + } + + private func restoreFloatingFrame(to window: NSWindow) { + if let frameString = UserDefaults.standard.string(forKey: kFloatingFrameKey) { + let frame = NSRectFromString(frameString) + if frame.width >= 100 && frame.height >= 100 { + window.setFrame(frame, display: false) + return + } + } + // No saved frame or invalid — use default + window.setContentSize(NSSize(width: 450, height: 600)) + window.center() + } + + func showFloatingWindow(viewModel: PRMonitorViewModel) { + // Close menu bar panel + NSApp.windows.forEach { window in + if window is NSPanel { window.orderOut(nil) } + } + + if let window = floatingWindow { + // Restore the saved frame before showing — NSHostingController + // may have shrunk the window while it was hidden. + restoreFloatingFrame(to: window) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + isFloatingMode = true + UserDefaults.standard.set(true, forKey: "useFloatingWindow") + return + } + + let contentView = MenuBarView() + .environmentObject(viewModel) + + let hostingController = NSHostingController(rootView: contentView) + // Prevent NSHostingController from shrinking the window to the + // SwiftUI content's intrinsic size. We manage size ourselves. + hostingController.sizingOptions = [] + + let window = NSWindow(contentViewController: hostingController) + window.title = "MonitorLizard" + window.styleMask = [.titled, .closable, .resizable, .miniaturizable] + window.level = .floating + window.minSize = NSSize(width: 400, height: 300) + window.isReleasedWhenClosed = false + + restoreFloatingFrame(to: window) + + // Delegate intercepts close to hide instead of destroy, + // preserving the window size and position. + floatingWindowDelegate.onClose = { [weak self] in + self?.saveFloatingFrame() + self?.isFloatingMode = false + } + window.delegate = floatingWindowDelegate + + floatingWindow = window + isFloatingMode = true + UserDefaults.standard.set(true, forKey: "useFloatingWindow") + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func hideFloatingWindow() { + saveFloatingFrame() + floatingWindow?.orderOut(nil) + isFloatingMode = false + UserDefaults.standard.set(false, forKey: "useFloatingWindow") + } + + func destroyFloatingWindow() { + saveFloatingFrame() + floatingWindow?.close() + floatingWindow = nil + isFloatingMode = false + UserDefaults.standard.set(false, forKey: "useFloatingWindow") + } + + func bringFloatingWindowToFront() { + floatingWindow?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func restoreFloatingWindowIfNeeded(viewModel: PRMonitorViewModel) { + if UserDefaults.standard.bool(forKey: "useFloatingWindow") { + showFloatingWindow(viewModel: viewModel) + } + } +} + +// MARK: - Floating Window Delegate + +/// Intercepts the window close button (⌘W / red button) and hides the +/// window instead of destroying it, preserving the frame for next show. +class FloatingWindowDelegate: NSObject, NSWindowDelegate { + var onClose: (() -> Void)? + + func windowShouldClose(_ sender: NSWindow) -> Bool { + sender.orderOut(nil) + onClose?() + return false // prevent actual close — just hide + } } diff --git a/MonitorLizard/ViewModels/PRMonitorViewModel.swift b/MonitorLizard/ViewModels/PRMonitorViewModel.swift index 2b047d7..20d4eb4 100644 --- a/MonitorLizard/ViewModels/PRMonitorViewModel.swift +++ b/MonitorLizard/ViewModels/PRMonitorViewModel.swift @@ -18,8 +18,20 @@ enum OtherPRError: LocalizedError { } } +struct PerUserPRCache { + var rawPRs: [PullRequest] = [] // Before ignored repos/checks filtering + var unsortedPRs: [PullRequest] = [] // After filtering, before sorting + var hasFailure: Bool = false + var hasPending: Bool = false +} + @MainActor class PRMonitorViewModel: ObservableObject { + private enum GlobalRulesDefaults { + static let ignoredReposKey = "globalIgnoredReposData" + static let ignoredChecksKey = "globalIgnoredChecksData" + } + @Published var pullRequests: [PullRequest] = [] @Published var otherPullRequests: [PullRequest] = [] @Published var isLoading = false @@ -35,26 +47,36 @@ class PRMonitorViewModel: ObservableObject { private let notificationService = NotificationService.shared private let otherPRsService: OtherPRsService private let customNamesService: CustomNamesService + let monitoredUsersService: MonitoredUsersService + private let prCacheService: PRCacheService private var refreshTimer: Timer? private var sortSettingObserver: AnyCancellable? private var reviewPRsSettingObserver: AnyCancellable? + private var monitoredUsersObserver: AnyCancellable? private var unsortedPullRequests: [PullRequest] = [] + private var perUserCache: [UUID: PerUserPRCache] = [:] @AppStorage("refreshInterval") private var refreshInterval: Int = Constants.defaultRefreshInterval + @AppStorage("disableAutoRefresh") private var disableAutoRefresh: Bool = false + @AppStorage("refreshOnStartup") private var refreshOnStartup: Bool = true + @AppStorage("enableQuietHours") private var enableQuietHours: Bool = false + @AppStorage("quietHoursStart") private var quietHoursStart: Int = Constants.defaultQuietHoursStart + @AppStorage("quietHoursEnd") private var quietHoursEnd: Int = Constants.defaultQuietHoursEnd + @AppStorage("quietHoursSkipWeekends") private var quietHoursSkipWeekends: Bool = true @AppStorage("sortNonSuccessFirst") private var sortNonSuccessFirst: Bool = false @AppStorage("enableInactiveBranchDetection") private var enableInactiveBranchDetection: Bool = false @AppStorage("inactiveBranchThresholdDays") private var inactiveBranchThresholdDays: Int = Constants.defaultInactiveBranchThreshold @AppStorage("showReviewPRs") private var showReviewPRs: Bool = true - // Computed property for available repositories + // MARK: - Computed Properties + var availableRepositories: [String] { let mainRepos = Set(unsortedPullRequests.map { $0.repository.nameWithOwner }) let otherRepos = Set(otherPullRequests.map { $0.repository.nameWithOwner }) return mainRepos.union(otherRepos).sorted() } - // Computed properties for filtering PRs by type and repository var authoredPRs: [PullRequest] { pullRequests.filter { $0.type == .authored } .filter { selectedRepository == "All Repositories" || $0.repository.nameWithOwner == selectedRepository } @@ -71,68 +93,236 @@ class PRMonitorViewModel: ObservableObject { .filter { selectedRepository == "All Repositories" || $0.repository.nameWithOwner == selectedRepository } } + var selectedUser: MonitoredUser? { + monitoredUsersService.selectedUser + } + + var monitoredUsers: [MonitoredUser] { + monitoredUsersService.users + } + + // MARK: - Init + init(isDemoMode: Bool = false, watchlistService: WatchlistService? = nil, otherPRsService: OtherPRsService? = nil, - customNamesService: CustomNamesService? = nil) { + customNamesService: CustomNamesService? = nil, + monitoredUsersService: MonitoredUsersService? = nil, + prCacheService: PRCacheService? = nil) { self.isDemoMode = isDemoMode self.githubService = GitHubService(isDemoMode: isDemoMode) self.watchlistService = watchlistService ?? .shared self.otherPRsService = otherPRsService ?? OtherPRsService() self.customNamesService = customNamesService ?? CustomNamesService() + self.monitoredUsersService = monitoredUsersService ?? .shared + self.prCacheService = prCacheService ?? .shared + restoreFromCache() setupNotifications() startPolling() observeSortSetting() observeReviewPRsSetting() + observeMonitoredUsers() + observeRefreshSettings() + observeHideInactiveSetting() } deinit { - // Timer invalidation is safe to call synchronously from deinit refreshTimer?.invalidate() sortSettingObserver?.cancel() reviewPRsSettingObserver?.cancel() + monitoredUsersObserver?.cancel() + usersChangeObserver?.cancel() + refreshSettingsObserver?.cancel() } + // MARK: - Observers + private func observeSortSetting() { sortSettingObserver = UserDefaults.standard .publisher(for: \.sortNonSuccessFirst) - .dropFirst() // Skip initial value + .dropFirst() .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.applySorting() } } + private var hideInactiveObserver: AnyCancellable? + + private func observeHideInactiveSetting() { + hideInactiveObserver = UserDefaults.standard + .publisher(for: \.hideInactivePRs) + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.reapplyFilters() + } + } + + private var refreshSettingsObserver: AnyCancellable? + + private func observeRefreshSettings() { + refreshSettingsObserver = UserDefaults.standard + .publisher(for: \.disableAutoRefresh) + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.startPolling() + } + } + private func observeReviewPRsSetting() { reviewPRsSettingObserver = UserDefaults.standard .publisher(for: \.showReviewPRs) - .dropFirst() // Skip initial value + .dropFirst() .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.objectWillChange.send() } } + private var usersChangeObserver: AnyCancellable? + + private func observeMonitoredUsers() { + monitoredUsersObserver = monitoredUsersService.$selectedUserId + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.switchToSelectedUser() + } + // Re-apply filters when user configs change (ignored repos/checks) + usersChangeObserver = monitoredUsersService.$users + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.reapplyFilters() + } + } + + private func switchToSelectedUser() { + guard let userId = monitoredUsersService.selectedUserId else { return } + if let cache = perUserCache[userId] { + unsortedPullRequests = cache.unsortedPRs + } else { + // New user with no cache yet — show empty list + unsortedPullRequests = [] + } + selectedRepository = "All Repositories" + applySorting() + } + + /// Re-applies ignored repos/checks filters from raw cached data for all users, + /// then updates the displayed list for the current user. + private func reapplyFilters() { + let users = monitoredUsersService.users + for user in users { + guard var cache = perUserCache[user.id], !cache.rawPRs.isEmpty else { continue } + let filtered = filterIgnoredRepos(cache.rawPRs, user: user) + let withIgnoredChecks = applyIgnoredChecks(filtered, user: user) + let withInactiveFiltered = filterInactivePRs(withIgnoredChecks) + cache.unsortedPRs = withInactiveFiltered + cache.hasFailure = cache.unsortedPRs.contains { + $0.buildStatus == .failure || $0.buildStatus == .error || + $0.buildStatus == .conflict || $0.reviewDecision == .changesRequested + } + cache.hasPending = cache.unsortedPRs.contains { $0.buildStatus == .pending } + perUserCache[user.id] = cache + } + + // Update displayed list for current user + let otherIDs = Set(otherPullRequests.map { $0.id }) + if let activeUserId = monitoredUsersService.selectedUserId, + let cache = perUserCache[activeUserId] { + unsortedPullRequests = cache.unsortedPRs.filter { !otherIDs.contains($0.id) } + } + applySorting() + } + + // MARK: - User Selection + + func selectUser(id: UUID) { + monitoredUsersService.selectUser(id: id) + } + + /// Returns the status color for a user's segment dot. + func userStatus(for userId: UUID) -> Color? { + guard let cache = perUserCache[userId] else { return nil } + if cache.hasFailure { return .red } + if cache.hasPending { return .orange } + return .green + } + + // MARK: - Cache Persistence + + private func restoreFromCache() { + let cached = prCacheService.loadPerUserCache() + guard !cached.isEmpty else { return } + + for (userId, data) in cached { + var cache = PerUserPRCache() + cache.rawPRs = data.rawPRs + cache.unsortedPRs = data.unsortedPRs + cache.hasFailure = data.unsortedPRs.contains { + $0.buildStatus == .failure || $0.buildStatus == .error || + $0.buildStatus == .conflict || $0.reviewDecision == .changesRequested + } + cache.hasPending = data.unsortedPRs.contains { $0.buildStatus == .pending } + perUserCache[userId] = cache + } + + let cachedOther = prCacheService.loadOtherPRs() + if !cachedOther.isEmpty { + otherPullRequests = cachedOther + } + + // Re-apply all filters (ignored repos/checks, inactive hiding) + // so settings changed since last persist take effect immediately. + reapplyFilters() + if !unsortedPullRequests.isEmpty || !otherPullRequests.isEmpty { + lastRefreshTime = Date() + } + } + + private func persistCache() { + let saveable = perUserCache.reduce(into: [UUID: (raw: [PullRequest], filtered: [PullRequest])]()) { dict, entry in + dict[entry.key] = (raw: entry.value.rawPRs, filtered: entry.value.unsortedPRs) + } + prCacheService.save(perUserCache: saveable, otherPRs: otherPullRequests) + } + + // MARK: - Polling + + private var hasStartedOnce = false + func startPolling() { - // Cancel existing timer refreshTimer?.invalidate() - // Create new timer + let isInitialStart = !hasStartedOnce + hasStartedOnce = true + let hasCache = !unsortedPullRequests.isEmpty || !otherPullRequests.isEmpty + + guard !disableAutoRefresh else { + if !hasCache { + Task { await refresh() } + } + return + } + refreshTimer = Timer.scheduledTimer( withTimeInterval: TimeInterval(refreshInterval), repeats: true ) { [weak self] _ in Task { @MainActor in - await self?.refresh() + guard let self, !self.isInQuietHours() else { return } + await self.refresh() } } - // Initial fetch - // Note: We skip checkGHAvailability() here because gh auth status can give misleading - // errors when offline (reports "token is invalid" instead of network error). - // Instead, we let the actual PR fetch determine if there's a network or auth issue. - Task { - await refresh() + // Decide whether to refresh immediately on startup + if isInitialStart && !refreshOnStartup && hasCache { + // Skip initial refresh — use cache, wait for timer + } else { + Task { await refresh() } } } @@ -143,117 +333,280 @@ class PRMonitorViewModel: ObservableObject { func updateRefreshInterval(_ interval: Int) { refreshInterval = interval - startPolling() // Restart timer with new interval + startPolling() + } + + private func isInQuietHours() -> Bool { + guard enableQuietHours else { return false } + + let now = Calendar.current.dateComponents([.hour, .weekday], from: Date()) + guard let hour = now.hour, let weekday = now.weekday else { return false } + + // weekday: 1 = Sunday, 7 = Saturday + if quietHoursSkipWeekends && (weekday == 1 || weekday == 7) { + return true + } + + let start = quietHoursStart + let end = quietHoursEnd + + if start < end { + // e.g. 09:00 - 17:00 + return hour >= start && hour < end + } else { + // e.g. 20:00 - 09:00 (crosses midnight) + return hour >= start || hour < end + } } + // MARK: - Refresh + func refresh() async { isLoading = true errorMessage = nil - // Start both fetches concurrently - async let mainFetchTask = githubService.fetchAllOpenPRs( - enableInactiveDetection: enableInactiveBranchDetection, - inactiveThresholdDays: inactiveBranchThresholdDays, - isDemoMode: isDemoMode - ) - async let otherFetchTask = fetchAllOtherPRs() + let refreshStart = ContinuousClock.now + let logger = RefreshLogger.shared + let users = monitoredUsersService.users + logger.log("Refresh started — \(users.count) user(s)") - do { - let fetchResult = try await mainFetchTask - let fetchedOther = await otherFetchTask - let fetchedPRs = fetchResult.pullRequests + // Fetch Other PRs concurrently with user PRs + async let otherFetchTask = fetchAllOtherPRs() - // Deduplicate: remove from main list any PR that's also in Other PRs - let otherIDs = Set(fetchedOther.map { $0.id }) - let dedupedPRs = fetchedPRs.filter { !otherIDs.contains($0.id) } + // Fetch all users concurrently + var userResults: [(UUID, MonitoredUser, Result)] = [] + await withTaskGroup(of: (UUID, MonitoredUser, Result).self) { group in + for user in users { + group.addTask { [githubService, enableInactiveBranchDetection, inactiveBranchThresholdDays, isDemoMode] in + let userStart = ContinuousClock.now + do { + let result: PRFetchResult + if isDemoMode { + result = PRFetchResult(pullRequests: DemoData.samplePullRequests, isPartial: false) + } else { + result = try await githubService.fetchPRsForUser( + username: user.username, + enableInactiveDetection: enableInactiveBranchDetection, + inactiveThresholdDays: inactiveBranchThresholdDays + ) + } + let elapsed = ContinuousClock.now - userStart + await logger.log("\(user.username): \(result.pullRequests.count) PRs in \(elapsed)") + return (user.id, user, .success(result)) + } catch { + let elapsed = ContinuousClock.now - userStart + await logger.log("\(user.username): FAILED in \(elapsed) — \(error.localizedDescription)") + return (user.id, user, .failure(error)) + } + } + } + for await result in group { + userResults.append(result) + } + } - // Check for watched PR completions across all PRs - let completed = watchlistService.checkForCompletions(currentPRs: dedupedPRs + fetchedOther) + let otherStart = ContinuousClock.now + let fetchedOther = await otherFetchTask + let otherElapsed = ContinuousClock.now - otherStart + if !otherPRsService.all().isEmpty { + logger.log("Other PRs: \(fetchedOther.count) in \(otherElapsed)") + } - // Send notifications for completed builds - for pr in completed { - notificationService.notifyBuildComplete(pr: pr, status: pr.buildStatus) + // Process results for each user + var anySuccess = false + for (userId, user, result) in userResults { + switch result { + case .success(let fetchResult): + anySuccess = true + // Store raw PRs with watch status and custom names (before filtering) + let rawPRs = applyCustomNames(fetchResult.pullRequests.map { pr in + var updated = pr + updated.isWatched = watchlistService.isWatched(pr) + return updated + }) + var cache = PerUserPRCache() + cache.rawPRs = rawPRs + // Apply ignore filters + let filtered = filterIgnoredRepos(rawPRs, user: user) + let withIgnoredChecks = applyIgnoredChecks(filtered, user: user) + let withInactiveFiltered = filterInactivePRs(withIgnoredChecks) + cache.unsortedPRs = withInactiveFiltered + cache.hasFailure = cache.unsortedPRs.contains { + $0.buildStatus == .failure || $0.buildStatus == .error || + $0.buildStatus == .conflict || $0.reviewDecision == .changesRequested + } + cache.hasPending = cache.unsortedPRs.contains { $0.buildStatus == .pending } + perUserCache[userId] = cache + + case .failure(let error): + print("Error fetching PRs for \(user.username): \(error)") + if let ghError = error as? GitHubError { + if ghError == .notInstalled || ghError == .notAuthenticated { + isGHAvailable = false + errorMessage = ghError.localizedDescription + } + } } + } - // Update PRs with watch status and custom names - unsortedPullRequests = applyCustomNames(dedupedPRs.map { pr in - var updated = pr - updated.isWatched = watchlistService.isWatched(pr) - return updated - }) - - otherPullRequests = applyCustomNames(fetchedOther.map { pr in - var updated = pr - updated.isWatched = watchlistService.isWatched(pr) - return updated - }) - - // Prune stale custom names for PRs no longer visible - let activeIDs = Set((dedupedPRs + fetchedOther).map { $0.id }) - customNamesService.pruneStale(keeping: activeIDs) - - // Apply sorting (also updates warning icon) - applySorting() - - // Reset filter if the selected repo no longer exists, but only when - // we have a complete result set. Partial results (one fetch failed) - // may be missing repos that still have open PRs. - if !fetchResult.isPartial && - selectedRepository != "All Repositories" && - !unsortedPullRequests.contains(where: { $0.repository.nameWithOwner == selectedRepository }) && - !otherPullRequests.contains(where: { $0.repository.nameWithOwner == selectedRepository }) { - selectedRepository = "All Repositories" + if !anySuccess && !users.isEmpty { + errorMessage = GitHubError.networkError.localizedDescription + } else if errorMessage == nil { + isGHAvailable = true + } + + // Update Other PRs + let otherIDs = Set(fetchedOther.map { $0.id }) + otherPullRequests = applyCustomNames(fetchedOther.map { pr in + var updated = pr + updated.isWatched = watchlistService.isWatched(pr) + return updated + }) + + // Check completions across all cached PRs + let allCachedPRs = Array(perUserCache.values.flatMap { $0.unsortedPRs }) + otherPullRequests + let completed = watchlistService.checkForCompletions(currentPRs: allCachedPRs) + for pr in completed { + notificationService.notifyBuildComplete(pr: pr, status: pr.buildStatus) + } + + // Prune stale custom names + let activeIDs = Set(allCachedPRs.map { $0.id }) + customNamesService.pruneStale(keeping: activeIDs) + + // Switch to current user's cache (re-read selectedUserId in case user switched during fetch) + let activeUserId = monitoredUsersService.selectedUserId + if let activeUserId, let cache = perUserCache[activeUserId] { + unsortedPullRequests = cache.unsortedPRs.filter { !otherIDs.contains($0.id) } + } else if let activeUserId, perUserCache[activeUserId] == nil { + // User was added but fetch hasn't returned data yet + unsortedPullRequests = [] + } + + applySorting() + + // Reset repo filter if needed + if selectedRepository != "All Repositories" && + !unsortedPullRequests.contains(where: { $0.repository.nameWithOwner == selectedRepository }) && + !otherPullRequests.contains(where: { $0.repository.nameWithOwner == selectedRepository }) { + selectedRepository = "All Repositories" + } + + lastRefreshTime = Date() + isLoading = false + persistCache() + let totalElapsed = ContinuousClock.now - refreshStart + logger.log("Refresh complete in \(totalElapsed)") + } + + // MARK: - Filtering Helpers + + private func filterInactivePRs(_ prs: [PullRequest]) -> [PullRequest] { + guard UserDefaults.standard.bool(forKey: "hideInactivePRs"), + UserDefaults.standard.bool(forKey: "enableInactiveBranchDetection") else { return prs } + let threshold = Double(inactiveBranchThresholdDays) + return prs.filter { pr in + // Check both the pre-computed status AND the actual date, + // because cached PRs may have been fetched before inactive + // detection was enabled and still have a non-inactive status. + if pr.buildStatus == .inactive { return false } + let daysSinceUpdate = Date().timeIntervalSince(pr.updatedAt) / Constants.secondsPerDay + return daysSinceUpdate < threshold + } + } + + private func filterIgnoredRepos(_ prs: [PullRequest], user: MonitoredUser) -> [PullRequest] { + let ignoredRepos = user.ignoredRepos + globalIgnoredRepos() + guard !ignoredRepos.isEmpty else { return prs } + let ignored = Set(ignoredRepos.map { $0.lowercased() }) + return prs.filter { !ignored.contains($0.repository.nameWithOwner.lowercased()) } + } + + private func applyIgnoredChecks(_ prs: [PullRequest], user: MonitoredUser) -> [PullRequest] { + let ignoredChecks = user.ignoredChecks + globalIgnoredChecks() + guard !ignoredChecks.isEmpty else { return prs } + return prs.map { pr in + var updated = pr + let repo = pr.repository.nameWithOwner + let nonIgnoredChecks = pr.statusChecks.filter { check in + !ignoredChecks.contains { rule in rule.matches(checkName: check.name, repo: repo) } } + let ignoredFailingCount = pr.statusChecks.filter { check in + (check.status == .failure || check.status == .error) && + ignoredChecks.contains { rule in rule.matches(checkName: check.name, repo: repo) } + }.count + updated.ignoredCheckCount = ignoredFailingCount + updated.statusChecks = nonIgnoredChecks + updated.buildStatus = computeStatus(from: nonIgnoredChecks, originalStatus: pr.buildStatus) + return updated + } + } - lastRefreshTime = Date() - isGHAvailable = true + private func globalIgnoredRepos() -> [String] { + guard let data = UserDefaults.standard.data(forKey: GlobalRulesDefaults.ignoredReposKey), + let repos = try? JSONDecoder().decode([String].self, from: data) else { + return [] + } + return repos + } - } catch let error as GitHubError { - print("GitHubError: \(error)") - errorMessage = error.localizedDescription - // Only mark as unavailable for installation/auth issues, not network errors - if error == .notInstalled || error == .notAuthenticated { - isGHAvailable = false + private func globalIgnoredChecks() -> [IgnoredCheckRule] { + guard let data = UserDefaults.standard.data(forKey: GlobalRulesDefaults.ignoredChecksKey), + let rules = try? JSONDecoder().decode([IgnoredCheckRule].self, from: data) else { + return [] + } + return rules + } + + private func computeStatus(from checks: [StatusCheck], originalStatus: BuildStatus) -> BuildStatus { + // Preserve conflict status (comes from mergeable, not checks) + if originalStatus == .conflict { return .conflict } + // Preserve inactive status + if originalStatus == .inactive { return .inactive } + + if checks.isEmpty { return .success } + + var hasFailure = false + var hasError = false + var hasPending = false + + for check in checks { + switch check.status { + case .failure: hasFailure = true + case .error: hasError = true + case .pending: hasPending = true + case .success, .skipped: break } - // Still update Other PRs even if main fetch failed - let fetchedOther = await otherFetchTask - otherPullRequests = applyCustomNames(fetchedOther.map { pr in - var updated = pr - updated.isWatched = watchlistService.isWatched(pr) - return updated - }) - } catch let error as ShellError { - print("ShellError: \(error)") - errorMessage = error.localizedDescription - let fetchedOther = await otherFetchTask - otherPullRequests = applyCustomNames(fetchedOther.map { pr in - var updated = pr - updated.isWatched = watchlistService.isWatched(pr) - return updated - }) - } catch let error as DecodingError { - print("DecodingError: \(error)") - errorMessage = "Failed to parse GitHub data. Please try again." - let fetchedOther = await otherFetchTask - otherPullRequests = applyCustomNames(fetchedOther.map { pr in - var updated = pr - updated.isWatched = watchlistService.isWatched(pr) - return updated - }) - } catch { - print("Unknown error: \(error)") - errorMessage = "An unexpected error occurred: \(error.localizedDescription)" - let fetchedOther = await otherFetchTask - otherPullRequests = applyCustomNames(fetchedOther.map { pr in - var updated = pr - updated.isWatched = watchlistService.isWatched(pr) - return updated - }) } - isLoading = false + if hasFailure { return .failure } + if hasError { return .error } + if hasPending { return .pending } + return .success } + private func updateGlobalWarningIcon() { + let anyBadStatus = perUserCache.values.contains { $0.hasFailure } + let anyInactive = perUserCache.values.contains { cache in + cache.unsortedPRs.contains { $0.buildStatus == .inactive } + } + let meId = monitoredUsersService.users.first(where: { $0.isMe })?.id + let anyReviewPRs: Bool + if let meId, let meCache = perUserCache[meId] { + anyReviewPRs = meCache.unsortedPRs.contains { $0.type == .reviewing } + } else { + anyReviewPRs = false + } + let otherBadStatus = otherPullRequests.contains { pr in + pr.buildStatus == .failure || pr.buildStatus == .error || + pr.buildStatus == .conflict || pr.buildStatus == .inactive || + pr.reviewDecision == .changesRequested + } + showWarningIcon = anyBadStatus || anyInactive || anyReviewPRs || otherBadStatus + } + + // MARK: - Other PRs + private func fetchAllOtherPRs() async -> [PullRequest] { let ids = otherPRsService.all() var results: [PullRequest] = [] @@ -269,37 +622,6 @@ class PRMonitorViewModel: ObservableObject { return results } - private func applySorting() { - // Split PRs by type - let authored = unsortedPullRequests.filter { $0.type == .authored } - let review = unsortedPullRequests.filter { $0.type == .reviewing } - - // Apply sorting independently within each section - let sortedAuthored = sortNonSuccessFirst ? sort(authored) : authored - let sortedReview = sortNonSuccessFirst ? sort(review) : review - - // Concatenate with review PRs first (prioritize unblocking teammates) - let newPullRequests = sortedReview + sortedAuthored - - // Only update the @Published property if data actually changed, to avoid - // unnecessary SwiftUI re-renders (which can freeze mid-scroll) - if newPullRequests != pullRequests { - pullRequests = newPullRequests - } - - // Update warning icon indicator (failures, errors, conflicts, changes requested, inactive PRs, or any review PRs) - let allDisplayed = newPullRequests + otherPullRequests - let hasBadStatus = allDisplayed.contains { pr in - let badBuild = pr.buildStatus == .failure || pr.buildStatus == .error - || pr.buildStatus == .conflict || pr.buildStatus == .inactive - return badBuild || pr.reviewDecision == .changesRequested - } - let hasReviewPRs = newPullRequests.contains { pr in - pr.type == .reviewing - } - showWarningIcon = hasBadStatus || hasReviewPRs - } - func addOtherPR(urlString: String) async throws { guard let id = GitHubService.parsePRURL(urlString) else { throw OtherPRError.invalidURL @@ -325,7 +647,6 @@ class PRMonitorViewModel: ObservableObject { updated.isWatched = watchlistService.isWatched(pr) updated.customName = customNamesService.name(for: pr.id) otherPullRequests.append(updated) - // Deduplicate from main list if this PR appeared there unsortedPullRequests.removeAll { $0.id == pr.id } pullRequests.removeAll { $0.id == pr.id } applySorting() @@ -351,6 +672,39 @@ class PRMonitorViewModel: ObservableObject { } } + // MARK: - Sorting + + private func applySorting() { + let authored = unsortedPullRequests.filter { $0.type == .authored } + let review = unsortedPullRequests.filter { $0.type == .reviewing } + + let sortedAuthored = sortNonSuccessFirst ? sort(authored) : authored + let sortedReview = sortNonSuccessFirst ? sort(review) : review + + let newPullRequests = sortedReview + sortedAuthored + + if newPullRequests != pullRequests { + pullRequests = newPullRequests + } + + updateGlobalWarningIcon() + } + + private func sort(_ prs: [PullRequest]) -> [PullRequest] { + prs.sorted { pr1, pr2 in + let nonSuccessStatuses: [BuildStatus] = [.failure, .error, .conflict, .pending, .inactive] + let pr1NonSuccess = nonSuccessStatuses.contains(pr1.buildStatus) || pr1.reviewDecision == .changesRequested + let pr2NonSuccess = nonSuccessStatuses.contains(pr2.buildStatus) || pr2.reviewDecision == .changesRequested + + if pr1NonSuccess != pr2NonSuccess { + return pr1NonSuccess + } + return false + } + } + + // MARK: - Custom Names + private func applyCustomNames(_ prs: [PullRequest]) -> [PullRequest] { prs.map { pr in var updated = pr @@ -370,21 +724,7 @@ class PRMonitorViewModel: ObservableObject { otherPullRequests = applyCustomNames(otherPullRequests) } - private func sort(_ prs: [PullRequest]) -> [PullRequest] { - prs.sorted { pr1, pr2 in - let nonSuccessStatuses: [BuildStatus] = [.failure, .error, .conflict, .pending, .inactive] - let pr1NonSuccess = nonSuccessStatuses.contains(pr1.buildStatus) || pr1.reviewDecision == .changesRequested - let pr2NonSuccess = nonSuccessStatuses.contains(pr2.buildStatus) || pr2.reviewDecision == .changesRequested - - // If one is non-success and other isn't, non-success comes first - if pr1NonSuccess != pr2NonSuccess { - return pr1NonSuccess - } - - // Otherwise maintain original order - return false - } - } + // MARK: - Watch func toggleWatch(for pr: PullRequest) { if watchlistService.isWatched(pr) { @@ -393,7 +733,6 @@ class PRMonitorViewModel: ObservableObject { watchlistService.watch(pr) } - // Update all arrays if let index = unsortedPullRequests.firstIndex(where: { $0.id == pr.id }) { unsortedPullRequests[index].isWatched.toggle() } @@ -418,13 +757,14 @@ class PRMonitorViewModel: ObservableObject { } } + // MARK: - GH Availability + private func checkGHAvailability() async { do { try await githubService.checkGHAvailable() isGHAvailable = true errorMessage = nil } catch let error as GitHubError { - // Only mark as unavailable for installation/auth issues, not network errors if error == .notInstalled || error == .notAuthenticated { isGHAvailable = false } @@ -451,4 +791,12 @@ extension UserDefaults { @objc dynamic var showReviewPRs: Bool { return bool(forKey: "showReviewPRs") } + + @objc dynamic var disableAutoRefresh: Bool { + return bool(forKey: "disableAutoRefresh") + } + + @objc dynamic var hideInactivePRs: Bool { + return bool(forKey: "hideInactivePRs") + } } diff --git a/MonitorLizard/Views/MenuBarView.swift b/MonitorLizard/Views/MenuBarView.swift index f275dbd..4a5fa93 100644 --- a/MonitorLizard/Views/MenuBarView.swift +++ b/MonitorLizard/Views/MenuBarView.swift @@ -2,17 +2,9 @@ import SwiftUI struct MenuBarView: View { @EnvironmentObject var viewModel: PRMonitorViewModel - // MenuBarExtra(.window) keeps its content window registered as a display cycle - // observer, causing SwiftUI to run a full layout+render pass at 60-120 Hz even - // when the panel is hidden. Swapping in an empty placeholder when not visible - // reduces per-frame work to nearly nothing. @State private var isWindowVisible = true var body: some View { - // WindowOcclusionObserver watches the specific NSWindow this view lives in - // via NSWindow.didChangeOcclusionStateNotification. This is more reliable than - // key-window notifications, which fire for any focus change (e.g. picker popups) - // and are not scoped to our window. ZStack { WindowOcclusionObserver { visible in isWindowVisible = visible @@ -25,6 +17,10 @@ struct MenuBarView: View { } } + private var isFloating: Bool { + WindowManager.shared.isFloatingMode + } + private var contentView: some View { VStack(spacing: 0) { // Header @@ -39,7 +35,7 @@ struct MenuBarView: View { ghUnavailableView } else if viewModel.isLoading && viewModel.authoredPRs.isEmpty && viewModel.reviewPRs.isEmpty && viewModel.otherPullRequests.isEmpty { loadingView - } else if viewModel.authoredPRs.isEmpty && viewModel.reviewPRs.isEmpty && viewModel.otherPullRequests.isEmpty && !viewModel.isLoading { + } else if viewModel.authoredPRs.isEmpty && viewModel.reviewPRs.isEmpty && viewModel.filteredOtherPRs.isEmpty && !viewModel.isLoading { emptyStateView } else { prListView @@ -50,45 +46,89 @@ struct MenuBarView: View { // Footer footerView } - .frame(minWidth: 400, maxWidth: 500) + .frame( + minWidth: 400, + maxWidth: isFloating ? .infinity : 500, + maxHeight: isFloating ? .infinity : nil + ) } private var headerView: some View { - HStack { - Text("Pull Requests for") - .font(.headline) - - Picker("", selection: $viewModel.selectedRepository) { - Text("All Repositories").tag("All Repositories") - Divider() - ForEach(viewModel.availableRepositories, id: \.self) { repo in - Text(repo.split(separator: "/").last.map(String.init) ?? repo).tag(repo) + VStack(spacing: 8) { + // User segment control (only if multiple users) + if viewModel.monitoredUsers.count > 1 { + HStack(spacing: 2) { + ForEach(viewModel.monitoredUsers) { user in + Button(action: { viewModel.selectUser(id: user.id) }) { + HStack(spacing: 4) { + if let color = viewModel.userStatus(for: user.id) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + } + Text(user.label) + .font(.caption) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + viewModel.selectedUser?.id == user.id + ? Color.accentColor.opacity(0.2) + : Color.clear + ) + .cornerRadius(6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + Spacer() } + .padding(.horizontal) + .padding(.top, 8) } - .labelsHidden() - .pickerStyle(.menu) - .fixedSize() - Spacer() + // Repo picker row + HStack { + Text("Pull Requests for") + .font(.headline) - if viewModel.isLoading { - ProgressView() - .scaleEffect(0.7) - .frame(width: 20, height: 20) - } else { - Button(action: { - Task { - await viewModel.refresh() + Picker("", selection: $viewModel.selectedRepository) { + Text("All Repositories").tag("All Repositories") + Divider() + ForEach(viewModel.availableRepositories, id: \.self) { repo in + Text(repo.split(separator: "/").last.map(String.init) ?? repo).tag(repo) } - }) { - Image(systemName: "arrow.clockwise") - .foregroundColor(.secondary) } - .buttonStyle(.plain) - .help("Refresh now") + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + + Spacer() + + if viewModel.isLoading { + ProgressView() + .scaleEffect(0.7) + .frame(width: 20, height: 20) + } else { + Button(action: { + Task { + await viewModel.refresh() + } + }) { + Image(systemName: "arrow.clockwise") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Refresh now") + } } + .padding(.horizontal) + .padding(.vertical, viewModel.monitoredUsers.count > 1 ? 4 : 0) } - .padding() + .padding(.top, viewModel.monitoredUsers.count > 1 ? 0 : 12) + .padding(.bottom, 8) } private func errorView(_ error: String) -> some View { @@ -116,7 +156,7 @@ struct MenuBarView: View { } } } - .frame(minHeight: 200, maxHeight: 300) + .frame(minHeight: 200, maxHeight: isFloating ? .infinity : 300) .frame(maxWidth: .infinity) } @@ -141,7 +181,7 @@ struct MenuBarView: View { } } } - .frame(minHeight: 200, maxHeight: 300) + .frame(minHeight: 200, maxHeight: isFloating ? .infinity : 300) .frame(maxWidth: .infinity) } @@ -154,13 +194,13 @@ struct MenuBarView: View { Text("No Open PRs") .font(.headline) - Text("You don't have any open pull requests at the moment.") + Text("No open pull requests found.") .font(.caption) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) } - .frame(minHeight: 200, maxHeight: 300) + .frame(minHeight: 200, maxHeight: isFloating ? .infinity : 300) .frame(maxWidth: .infinity) } @@ -168,32 +208,30 @@ struct MenuBarView: View { ContentUnavailableView { Label("Loading Pull Requests", systemImage: "arrow.circlepath") } description: { - Text("Fetching your open PRs from GitHub...") + Text("Fetching open PRs from GitHub...") } - .frame(minHeight: 200, maxHeight: 300) + .frame(minHeight: 200, maxHeight: isFloating ? .infinity : 300) .frame(maxWidth: .infinity) } private var prListView: some View { let totalPRs = viewModel.authoredPRs.count + viewModel.reviewPRs.count + viewModel.filteredOtherPRs.count - let estimatedRowHeight: CGFloat = 120 // Approximate height per PR row + let estimatedRowHeight: CGFloat = 120 let sectionHeaderHeight: CGFloat = 40 - let numSections = (viewModel.reviewPRs.isEmpty ? 0 : 1) + let showReview = viewModel.selectedUser?.isMe == true && !viewModel.reviewPRs.isEmpty + let numSections = (showReview ? 1 : 0) + (viewModel.authoredPRs.isEmpty ? 0 : 1) + (viewModel.filteredOtherPRs.isEmpty ? 0 : 1) let estimatedContentHeight = CGFloat(totalPRs) * estimatedRowHeight + CGFloat(numSections) * sectionHeaderHeight let maxHeight = calculateMaxHeight() - let targetHeight = min(estimatedContentHeight, maxHeight) - return ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 0) { - // Dedicated scroll anchor — stable ID never moves between views Color.clear.frame(height: 0).id("top") - // Review PRs Section (FIRST - prioritize unblocking teammates) - if !viewModel.reviewPRs.isEmpty { - sectionHeader(type: .reviewing, count: viewModel.reviewPRs.count) + // Review PRs Section (only for @me) + if showReview { + sectionHeader(type: .reviewing, count: viewModel.reviewPRs.count, username: nil) .id("header-review") ForEach(viewModel.reviewPRs) { pr in @@ -204,9 +242,9 @@ struct MenuBarView: View { } } - // Other PRs Section (SECOND) + // Other PRs Section if !viewModel.filteredOtherPRs.isEmpty { - sectionHeader(type: .other, count: viewModel.filteredOtherPRs.count) + sectionHeader(type: .other, count: viewModel.filteredOtherPRs.count, username: nil) .id("header-other") ForEach(viewModel.filteredOtherPRs) { pr in @@ -217,9 +255,9 @@ struct MenuBarView: View { } } - // Authored PRs Section (THIRD) + // Authored PRs Section if !viewModel.authoredPRs.isEmpty { - sectionHeader(type: .authored, count: viewModel.authoredPRs.count) + sectionHeader(type: .authored, count: viewModel.authoredPRs.count, username: viewModel.selectedUser?.username) .id("header-authored") ForEach(viewModel.authoredPRs) { pr in @@ -231,7 +269,8 @@ struct MenuBarView: View { } } } - .frame(height: targetHeight) + .frame(height: isFloating ? nil : min(estimatedContentHeight, maxHeight)) + .frame(maxHeight: isFloating ? .infinity : nil) .id(viewModel.selectedRepository) .onAppear { proxy.scrollTo("top", anchor: .top) @@ -239,9 +278,9 @@ struct MenuBarView: View { } } - private func sectionHeader(type: PRType, count: Int) -> some View { + private func sectionHeader(type: PRType, count: Int, username: String?) -> some View { HStack { - Text(type.displayTitle(count: count)) + Text(type.displayTitle(count: count, username: username)) .font(.headline) .foregroundColor(.primary) @@ -257,12 +296,11 @@ struct MenuBarView: View { } private func calculateMaxHeight() -> CGFloat { - // Get screen height and use 70% of it, max 700px if let screen = NSScreen.main { let maxHeight = screen.visibleFrame.height * Constants.menuMaxHeightMultiplier return min(maxHeight, 700) } - return 600 // Fallback + return 600 } private var footerView: some View { @@ -282,6 +320,18 @@ struct MenuBarView: View { Divider() + if WindowManager.shared.isFloatingMode { + Button("Attach to Menu Bar") { + WindowManager.shared.destroyFloatingWindow() + } + } else { + Button("Detach as Floating Window") { + WindowManager.shared.showFloatingWindow(viewModel: viewModel) + } + } + + Divider() + Button("Settings") { WindowManager.shared.showSettings() } @@ -325,10 +375,6 @@ struct MenuBarView: View { } } -/// Watches the NSWindow this view is embedded in and calls `onChange` whenever -/// the window's occlusion state changes (i.e. it becomes visible or is ordered out). -/// Uses NSWindow.didChangeOcclusionStateNotification scoped to the specific window, -/// so it is not affected by other windows gaining/losing focus. struct WindowOcclusionObserver: NSViewRepresentable { let onChange: (Bool) -> Void @@ -360,30 +406,25 @@ struct WindowOcclusionObserver: NSViewRepresentable { occlusionObserver = nil guard let window else { return } - // Use key-window notification to detect show: fires even when the window - // is zero-sized (which occlusion state never reports as .visible). - let becomeKey = NotificationCenter.default.addObserver( - forName: NSWindow.didBecomeKeyNotification, - object: window, - queue: .main - ) { [weak self] _ in self?.onChange(true) } - - // Use occlusion state to detect hide: more reliable than resign-key because - // it only fires when the window is actually ordered out, not when a picker - // popup temporarily steals focus. - let occlusion = NotificationCenter.default.addObserver( - forName: NSWindow.didChangeOcclusionStateNotification, - object: window, - queue: .main - ) { [weak window, weak self] _ in - guard let window, let self else { return } - if !window.occlusionState.contains(.visible) { - self.onChange(false) + let becomeKey = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in self?.onChange(true) } + + let occlusion = NotificationCenter.default.addObserver( + forName: NSWindow.didChangeOcclusionStateNotification, + object: window, + queue: .main + ) { [weak window, weak self] _ in + guard let window, let self else { return } + if !window.occlusionState.contains(.visible) { + self.onChange(false) + } } - } - observer = becomeKey - occlusionObserver = occlusion + observer = becomeKey + occlusionObserver = occlusion } deinit { diff --git a/MonitorLizard/Views/PRRowView.swift b/MonitorLizard/Views/PRRowView.swift index 9d19f6f..8e6dbda 100644 --- a/MonitorLizard/Views/PRRowView.swift +++ b/MonitorLizard/Views/PRRowView.swift @@ -6,11 +6,6 @@ struct PRRowView: View { @State private var isHovering = false - private var daysSinceUpdate: Int { - let days = Int(Date().timeIntervalSince(pr.updatedAt) / Constants.secondsPerDay) - return days - } - private func openPRURL() { // Close the menu bar extra by ordering out all panels NSApp.windows.forEach { window in @@ -25,13 +20,40 @@ struct PRRowView: View { } } - private var daysSinceUpdateText: String { - if daysSinceUpdate == 0 { - return "updated today" - } else if daysSinceUpdate == 1 { - return "updated 1 day ago" + private var updatedAgoText: String { + let seconds = Int(Date().timeIntervalSince(pr.updatedAt)) + if seconds < 60 { + return "just updated" + } else if seconds < 3600 { + let minutes = seconds / 60 + return minutes == 1 ? "updated 1 minute ago" : "updated \(minutes) minutes ago" + } else if seconds < 86400 { + let hours = seconds / 3600 + return hours == 1 ? "updated 1 hour ago" : "updated \(hours) hours ago" } else { - return "updated \(daysSinceUpdate) days ago" + let days = seconds / 86400 + return days == 1 ? "updated 1 day ago" : "updated \(days) days ago" + } + } + + private var buildStatusText: String { + if pr.buildStatus == .pending { + let pendingCount = pr.statusChecks.filter { $0.status == .pending }.count + return pendingCount > 0 ? "\(pendingCount) checks pending" : pr.buildStatus.displayName + } + if pr.buildStatus == .failure || pr.buildStatus == .error { + let pendingCount = pr.statusChecks.filter { $0.status == .pending }.count + return pendingCount > 0 ? "\(pr.buildStatus.displayName) (\(pendingCount) pending)" : pr.buildStatus.displayName + } + return pr.buildStatus.displayName + } + + private var pendingChecksTooltipLines: [InstantTooltip.Line] { + guard pr.buildStatus == .pending || pr.buildStatus == .failure || pr.buildStatus == .error else { return [] } + return pr.statusChecks + .filter { $0.status == .pending } + .map { check in + InstantTooltip.Line(icon: check.status.icon, text: check.name) } } @@ -154,21 +176,16 @@ struct PRRowView: View { .lineLimit(3) .fixedSize(horizontal: false, vertical: true) - // Repo and PR number + // Repo, PR number, branch HStack(spacing: 4) { Text(pr.repository.name) .font(.caption) .foregroundColor(.secondary) - Text("•") - .font(.caption) - .foregroundColor(.secondary) - Text("#\(pr.number)") .font(.caption) .foregroundColor(.secondary) - // Draft badge if pr.isDraft { Text("DRAFT") .font(.caption2) @@ -179,11 +196,8 @@ struct PRRowView: View { .background(Color.orange.opacity(0.8)) .cornerRadius(3) } - } - // Branch name - if !pr.headRefName.isEmpty { - HStack(spacing: 4) { + if !pr.headRefName.isEmpty { Image(systemName: "arrow.branch") .font(.caption2) .foregroundColor(.secondary) @@ -195,24 +209,9 @@ struct PRRowView: View { } } - // Build status text with days since update - HStack(spacing: 4) { - Text(pr.buildStatus.displayName) - .font(.caption2) - .foregroundColor(pr.buildStatus.color) - - Text("•") - .font(.caption2) - .foregroundColor(.secondary) - - Text("(\(daysSinceUpdateText))") - .font(.caption2) - .foregroundColor(.secondary) - } - // Labels if !pr.labels.isEmpty { - HStack(spacing: 4) { + FlowLayout(spacing: 4) { ForEach(pr.labels) { label in let bgColor = Color(hex: label.color) Text(label.name) @@ -222,8 +221,35 @@ struct PRRowView: View { .padding(.vertical, 2) .background(bgColor) .cornerRadius(3) + .fixedSize() + } + } + } + + // Build status text with days since update + HStack(spacing: 4) { + Text(buildStatusText) + .font(.caption2) + .foregroundColor(pr.buildStatus.color) + .overlay { + if !pendingChecksTooltipLines.isEmpty { + InstantTooltip(lines: pendingChecksTooltipLines) + } } + + if pr.ignoredCheckCount > 0 { + Text("(\(pr.ignoredCheckCount) ignored)") + .font(.caption2) + .foregroundColor(.secondary) } + + Text("•") + .font(.caption2) + .foregroundColor(.secondary) + + Text(updatedAgoText) + .font(.caption2) + .foregroundColor(.secondary) } // Failing checks (only shown when checks fail) @@ -249,83 +275,72 @@ struct PRRowView: View { } } - Spacer() - - // Action buttons - 2x2 grid: [Watch, Open] / [Delete, Rename] - // Always occupies space; opacity controls visibility. - VStack(spacing: 0) { - Spacer(minLength: 0) - VStack(spacing: 6) { - // Top row: Watch + Open - HStack(spacing: 8) { - // Watch - fixed cell; hidden when no status checks - if pr.hasStatusChecks { - Button(action: { viewModel.toggleWatch(for: pr) }) { - Image(systemName: pr.isWatched ? "eye.fill" : "eye") - .foregroundColor(pr.isWatched ? .blue : .gray) - } - .buttonStyle(.plain) - .help(pr.isWatched ? "Stop watching this PR" : "Watch this PR for completion") - .opacity(isHovering || pr.isWatched ? 1.0 : 0.0) - .frame(width: 16, height: 16) - } else { - Color.clear.frame(width: 16, height: 16) - } - - // Open in browser - Button(action: openPRURL) { - Image(systemName: "arrow.up.right.square") - .foregroundColor(.gray) - } - .buttonStyle(.plain) - .help("Open in GitHub") - .opacity(isHovering ? 1.0 : 0.0) - .frame(width: 16, height: 16) + Spacer(minLength: 0) + + // Action buttons - vertical strip at right edge. + // Always rendered with fixed width to reserve space and + // prevent layout shifts (title/tags rewrapping) on hover. + VStack(spacing: 4) { + if pr.hasStatusChecks { + Button(action: { viewModel.toggleWatch(for: pr) }) { + Image(systemName: pr.isWatched ? "eye.fill" : "eye") + .foregroundColor(pr.isWatched ? .blue : .gray) + .font(.caption) } + .buttonStyle(.plain) + .help(pr.isWatched ? "Stop watching this PR" : "Watch this PR for completion") + .opacity(isHovering || pr.isWatched ? 1.0 : 0.0) + .frame(width: 16, height: 16) + } - // Bottom row: Delete (Other PRs only) + Rename - HStack(spacing: 8) { - if pr.type == .other { - Button(action: { - NSApp.windows.forEach { window in - if window is NSPanel { window.orderOut(nil) } - } - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = "Remove from Other PRs?" - alert.informativeText = "\"\(pr.displayTitle)\" will be removed from Other PRs." - alert.addButton(withTitle: "Remove") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - if alert.runModal() == .alertFirstButtonReturn { - viewModel.removeOtherPR(pr) - } - } - }) { - Image(systemName: "trash") - .foregroundColor(.red) - } - .buttonStyle(.plain) - .help("Remove from Other PRs") - .opacity(isHovering ? 1.0 : 0.0) - .frame(width: 16, height: 16) - } else { - Color.clear.frame(width: 16, height: 16) + Button(action: openPRURL) { + Image(systemName: "arrow.up.right.square") + .foregroundColor(.gray) + .font(.caption) + } + .buttonStyle(.plain) + .help("Open in GitHub") + .opacity(isHovering ? 1.0 : 0.0) + .frame(width: 16, height: 16) + + if pr.type == .other { + Button(action: { + NSApp.windows.forEach { window in + if window is NSPanel { window.orderOut(nil) } } - - Button(action: showRenameDialog) { - Image(systemName: "pencil") - .foregroundColor(pr.customName != nil ? .blue : .gray) + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Remove from Other PRs?" + alert.informativeText = "\"\(pr.displayTitle)\" will be removed from Other PRs." + alert.addButton(withTitle: "Remove") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + if alert.runModal() == .alertFirstButtonReturn { + viewModel.removeOtherPR(pr) + } } - .buttonStyle(.plain) - .help(pr.customName != nil ? "Edit custom name" : "Set custom name") - .opacity(isHovering ? 1.0 : 0.0) - .frame(width: 16, height: 16) + }) { + Image(systemName: "trash") + .foregroundColor(.red) + .font(.caption) } + .buttonStyle(.plain) + .help("Remove from Other PRs") + .opacity(isHovering ? 1.0 : 0.0) + .frame(width: 16, height: 16) + } + + Button(action: showRenameDialog) { + Image(systemName: "pencil") + .foregroundColor(pr.customName != nil ? .blue : .gray) + .font(.caption) } - Spacer(minLength: 0) + .buttonStyle(.plain) + .help(pr.customName != nil ? "Edit custom name" : "Set custom name") + .opacity(isHovering ? 1.0 : 0.0) + .frame(width: 16, height: 16) } - .frame(width: 44) // Fixed width to prevent layout shift + .frame(width: 16) } .padding(.horizontal, 12) .padding(.vertical, 10) @@ -351,6 +366,241 @@ struct PRRowView: View { } } +/// An NSViewRepresentable that sets an instant tooltip on the parent view. +/// Unlike SwiftUI's `.help()`, this shows immediately on hover and works +/// even when the app is not focused (menu bar / floating window). +struct InstantTooltip: NSViewRepresentable { + struct Line { + let icon: String + let text: String + } + + let lines: [Line] + + func makeNSView(context: Context) -> TooltipView { + TooltipView(lines: lines) + } + + func updateNSView(_ nsView: TooltipView, context: Context) { + nsView.updateTooltip(lines) + } + + class TooltipView: NSView { + private var lines: [Line] + private var trackingArea: NSTrackingArea? + private var tooltipWindow: NSWindow? + private var scrollObserver: NSObjectProtocol? + + init(lines: [Line]) { + self.lines = lines + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { fatalError() } + + func updateTooltip(_ lines: [Line]) { + self.lines = lines + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let area = trackingArea { + removeTrackingArea(area) + } + let area = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + trackingArea = area + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateScrollObserver() + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + updateScrollObserver() + } + + override func mouseEntered(with event: NSEvent) { + showTooltip() + } + + override func mouseExited(with event: NSEvent) { + hideTooltip() + } + + override func removeFromSuperview() { + removeScrollObserver() + hideTooltip() + super.removeFromSuperview() + } + + deinit { + removeScrollObserver() + } + + private func showTooltip() { + guard !lines.isEmpty else { return } + hideTooltip() + + let padding: CGFloat = 6 + let rowSpacing: CGFloat = 3 + let iconFont = NSFont(name: "Apple Color Emoji", size: NSFont.smallSystemFontSize) ?? .systemFont(ofSize: NSFont.smallSystemFontSize) + let textFont = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + + let rows = lines.map { line -> NSView in + let iconLabel = NSTextField(labelWithString: line.icon) + iconLabel.font = iconFont + iconLabel.backgroundColor = .clear + + let textLabel = NSTextField(labelWithString: line.text) + textLabel.font = textFont + textLabel.textColor = .labelColor + textLabel.backgroundColor = .clear + + let row = NSStackView(views: [iconLabel, textLabel]) + row.orientation = .horizontal + row.alignment = .firstBaseline + row.spacing = 6 + return row + } + + let stack = NSStackView(views: rows) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = rowSpacing + stack.edgeInsets = NSEdgeInsets(top: padding, left: padding, bottom: padding, right: padding) + stack.layoutSubtreeIfNeeded() + + let fittingSize = stack.fittingSize + let contentSize = NSSize(width: fittingSize.width, height: fittingSize.height) + + let contentView = NSView(frame: NSRect(origin: .zero, size: contentSize)) + contentView.wantsLayer = true + contentView.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + contentView.layer?.borderColor = NSColor.separatorColor.cgColor + contentView.layer?.borderWidth = 0.5 + contentView.layer?.cornerRadius = 4 + stack.frame = contentView.bounds + stack.autoresizingMask = [.width, .height] + contentView.addSubview(stack) + + let screenPoint = window?.convertPoint(toScreen: convert(NSPoint(x: bounds.midX, y: bounds.maxY), to: nil)) ?? .zero + let origin = NSPoint( + x: screenPoint.x - contentSize.width / 2, + y: screenPoint.y + 4 + ) + + let tipWindow = NSWindow( + contentRect: NSRect(origin: origin, size: contentSize), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + tipWindow.isOpaque = false + tipWindow.backgroundColor = .clear + tipWindow.level = .floating + tipWindow.contentView = contentView + tipWindow.orderFront(nil) + tooltipWindow = tipWindow + } + + private func hideTooltip() { + tooltipWindow?.orderOut(nil) + tooltipWindow = nil + } + + private func updateScrollObserver() { + removeScrollObserver() + guard let clipView = enclosingScrollView()?.contentView else { return } + clipView.postsBoundsChangedNotifications = true + scrollObserver = NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: clipView, + queue: .main + ) { [weak self] _ in + self?.hideTooltipIfMouseLeftBounds() + } + } + + private func removeScrollObserver() { + if let scrollObserver { + NotificationCenter.default.removeObserver(scrollObserver) + self.scrollObserver = nil + } + } + + private func hideTooltipIfMouseLeftBounds() { + guard tooltipWindow != nil, let window else { return } + let mouseLocationInWindow = window.mouseLocationOutsideOfEventStream + let mouseLocationInView = convert(mouseLocationInWindow, from: nil) + if !bounds.contains(mouseLocationInView) { + hideTooltip() + } + } + + private func enclosingScrollView() -> NSScrollView? { + var currentView = superview + while let view = currentView { + if let scrollView = view as? NSScrollView { + return scrollView + } + currentView = view.superview + } + return nil + } + } +} + +/// A simple wrapping flow layout that places children left-to-right, +/// moving to the next row when a child would exceed the available width. +struct FlowLayout: Layout { + var spacing: CGFloat = 4 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > maxWidth && x > 0 { + y += rowHeight + spacing + x = 0 + rowHeight = 0 + } + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + return CGSize(width: maxWidth, height: y + rowHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x: CGFloat = bounds.minX + var y: CGFloat = bounds.minY + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > bounds.maxX && x > bounds.minX { + y += rowHeight + spacing + x = bounds.minX + rowHeight = 0 + } + subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} + extension Color { init(hex: String) { let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) diff --git a/MonitorLizard/Views/SettingsView.swift b/MonitorLizard/Views/SettingsView.swift index 3d0cec5..64f162f 100644 --- a/MonitorLizard/Views/SettingsView.swift +++ b/MonitorLizard/Views/SettingsView.swift @@ -1,7 +1,17 @@ import SwiftUI struct SettingsView: View { + private static let globalRulesSelectionId = "global_rules" + private static let globalIgnoredReposKey = "globalIgnoredReposData" + private static let globalIgnoredChecksKey = "globalIgnoredChecksData" + @AppStorage("refreshInterval") private var refreshInterval = Constants.defaultRefreshInterval + @AppStorage("disableAutoRefresh") private var disableAutoRefresh = false + @AppStorage("refreshOnStartup") private var refreshOnStartup = true + @AppStorage("enableQuietHours") private var enableQuietHours = false + @AppStorage("quietHoursStart") private var quietHoursStart = Constants.defaultQuietHoursStart + @AppStorage("quietHoursEnd") private var quietHoursEnd = Constants.defaultQuietHoursEnd + @AppStorage("quietHoursSkipWeekends") private var quietHoursSkipWeekends = true @AppStorage("sortNonSuccessFirst") private var sortNonSuccessFirst = false @AppStorage("showReviewPRs") private var showReviewPRs = true @AppStorage("enableSounds") private var enableSounds = true @@ -10,6 +20,12 @@ struct SettingsView: View { @AppStorage("showNotifications") private var showNotifications = true @AppStorage("enableInactiveBranchDetection") private var enableInactiveBranchDetection = false @AppStorage("inactiveBranchThresholdDays") private var inactiveBranchThresholdDays = Constants.defaultInactiveBranchThreshold + @AppStorage("hideInactivePRs") private var hideInactivePRs = false + @AppStorage("useFloatingWindow") private var useFloatingWindow = false + @AppStorage(Self.globalIgnoredReposKey) private var globalIgnoredReposData: Data = Data() + @AppStorage(Self.globalIgnoredChecksKey) private var globalIgnoredChecksData: Data = Data() + + @StateObject private var monitoredUsersService = MonitoredUsersService.shared var body: some View { TabView { @@ -18,6 +34,11 @@ struct SettingsView: View { Label("General", systemImage: "gear") } + usersSettings + .tabItem { + Label("Users", systemImage: "person.2") + } + notificationSettings .tabItem { Label("Notifications", systemImage: "bell") @@ -32,6 +53,8 @@ struct SettingsView: View { .padding() } + // MARK: - General + private var generalSettings: some View { Form { Section { @@ -39,20 +62,73 @@ struct SettingsView: View { Text("Refresh Interval") .font(.headline) - HStack { - Slider(value: Binding( - get: { Double(refreshInterval) }, - set: { refreshInterval = Int($0) } - ), in: Double(Constants.minRefreshInterval)...Double(Constants.maxRefreshInterval), step: Double(Constants.refreshIntervalStep)) + Toggle("Disable auto refresh", isOn: $disableAutoRefresh) + .help("Only refresh manually via the refresh button") + + Toggle("Refresh on startup", isOn: $refreshOnStartup) + .help("When off, uses cached data on launch and waits for the next scheduled refresh") + + if !disableAutoRefresh { + HStack { + Slider(value: Binding( + get: { Double(min(refreshInterval, Constants.maxRefreshInterval)) }, + set: { refreshInterval = max(Constants.minRefreshInterval, Int($0)) } + ), in: Double(Constants.minRefreshInterval)...Double(Constants.maxRefreshInterval), step: Double(Constants.refreshIntervalStep)) + + TextField("", value: $refreshInterval, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 55) + .multilineTextAlignment(.trailing) + .onSubmit { + if refreshInterval < Constants.minRefreshInterval { + refreshInterval = Constants.minRefreshInterval + } + } + + Text("s") + .foregroundColor(.secondary) + } - Text("\(refreshInterval)s") - .frame(width: 50, alignment: .trailing) + Text("How often to check for PR status updates") + .font(.caption) .foregroundColor(.secondary) - } - Text("How often to check for PR status updates") - .font(.caption) - .foregroundColor(.secondary) + Divider() + + Toggle("Quiet hours", isOn: $enableQuietHours) + .help("Pause auto refresh during specified hours") + + if enableQuietHours { + HStack(spacing: 8) { + Text("Pause from") + .font(.caption) + .foregroundColor(.secondary) + Picker("", selection: $quietHoursStart) { + ForEach(0..<24, id: \.self) { hour in + Text(String(format: "%02d:00", hour)).tag(hour) + } + } + .labelsHidden() + .frame(width: 80) + Text("to") + .font(.caption) + .foregroundColor(.secondary) + Picker("", selection: $quietHoursEnd) { + ForEach(0..<24, id: \.self) { hour in + Text(String(format: "%02d:00", hour)).tag(hour) + } + } + .labelsHidden() + .frame(width: 80) + } + + Toggle("Also pause on weekends", isOn: $quietHoursSkipWeekends) + + Text("Auto refresh is paused during quiet hours. You can still refresh manually.") + .font(.caption) + .foregroundColor(.secondary) + } + } } .padding(.vertical, 8) } @@ -92,7 +168,10 @@ struct SettingsView: View { in: Constants.minInactiveBranchThreshold...Constants.maxInactiveBranchThreshold) .padding(.top, 4) - Text("PRs not updated for \(inactiveBranchThresholdDays) days will show as inactive") + Toggle("Hide inactive PRs", isOn: $hideInactivePRs) + .help("Completely hide PRs that are inactive instead of just marking them") + + Text("PRs not updated for \(inactiveBranchThresholdDays) days will \(hideInactivePRs ? "be hidden" : "show as inactive")") .font(.caption) .foregroundColor(.secondary) .padding(.top, 4) @@ -101,6 +180,23 @@ struct SettingsView: View { .padding(.vertical, 8) } + Section { + VStack(alignment: .leading, spacing: 8) { + Toggle("Keep as floating window", isOn: $useFloatingWindow) + .help("Show MonitorLizard as a floating window instead of a menu bar dropdown") + .onChange(of: useFloatingWindow) { _, newValue in + if !newValue { + WindowManager.shared.destroyFloatingWindow() + } + } + + Text("Show as a draggable floating window. Close the window to return to menu bar mode.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 8) + } + Section { VStack(alignment: .leading, spacing: 12) { Text("About Polling") @@ -117,8 +213,468 @@ struct SettingsView: View { .formStyle(.grouped) } + // MARK: - Users + + @State private var selectedSettingsUserId: String? + @State private var showAddUser = false + @State private var newUsername = "" + @State private var newDisplayName = "" + @State private var newIgnoredRepo = "" + @State private var newCheckPattern = "" + @State private var newCheckRepo = "*" + + private var selectedSettingsUser: MonitoredUser? { + guard let selectedSettingsUserId, + selectedSettingsUserId != Self.globalRulesSelectionId else { return nil } + return monitoredUsersService.users.first { $0.id.uuidString == selectedSettingsUserId } + } + + private var isGlobalRulesSelected: Bool { + selectedSettingsUserId == Self.globalRulesSelectionId + } + + private var globalIgnoredRepos: [String] { + get { decodeGlobalRepos() } + nonmutating set { saveGlobalRepos(newValue) } + } + + private var globalIgnoredChecks: [IgnoredCheckRule] { + get { decodeGlobalChecks() } + nonmutating set { saveGlobalChecks(newValue) } + } + + private var usersSettings: some View { + HSplitView { + // Left: user list + VStack(spacing: 0) { + List(selection: $selectedSettingsUserId) { + HStack { + Text("global_rules") + } + .tag(Self.globalRulesSelectionId) + + ForEach(monitoredUsersService.users) { user in + HStack { + Text(user.label) + if user.isMe { + Text("(you)") + .font(.caption) + .foregroundColor(.secondary) + } + } + .tag(user.id.uuidString) + } + } + .listStyle(.sidebar) + + Divider() + + HStack { + Button(action: { showAddUser = true }) { + Image(systemName: "plus") + } + .buttonStyle(.plain) + .help("Add a GitHub user to monitor") + + Button(action: { + if let id = selectedSettingsUserId, + let uuid = UUID(uuidString: id) { + monitoredUsersService.removeUser(id: uuid) + selectedSettingsUserId = monitoredUsersService.users.first?.id + .uuidString ?? Self.globalRulesSelectionId + } + }) { + Image(systemName: "minus") + } + .buttonStyle(.plain) + .disabled(selectedSettingsUser?.isMe == true || isGlobalRulesSelected || selectedSettingsUserId == nil) + .help("Remove selected user") + + Spacer() + } + .padding(8) + } + .frame(minWidth: 140, maxWidth: 180) + + // Right: selected user config + if isGlobalRulesSelected { + globalRulesDetailView + } else if let user = selectedSettingsUser { + userDetailView(user: user) + } else { + Text("Select a user") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .onAppear { + if selectedSettingsUserId == nil { + selectedSettingsUserId = Self.globalRulesSelectionId + } + } + .sheet(isPresented: $showAddUser) { + addUserSheet + } + } + + private func userDetailView(user: MonitoredUser) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Username + VStack(alignment: .leading, spacing: 4) { + Text("Username") + .font(.caption) + .foregroundColor(.secondary) + if user.isMe { + Text("@me (authenticated user)") + .font(.body) + } else { + Text(user.username) + .font(.body) + } + } + + // Display Name + VStack(alignment: .leading, spacing: 4) { + Text("Display Name") + .font(.caption) + .foregroundColor(.secondary) + TextField("Optional label for tab", text: Binding( + get: { user.displayName ?? "" }, + set: { newValue in + var updated = user + updated.displayName = newValue.isEmpty ? nil : newValue + monitoredUsersService.updateUser(updated) + } + )) + .textFieldStyle(.roundedBorder) + } + + Divider() + + // Ignored Repositories + VStack(alignment: .leading, spacing: 8) { + Text("Ignored Repositories") + .font(.headline) + Text("PRs from these repos won't be shown.") + .font(.caption) + .foregroundColor(.secondary) + + ForEach(Array(user.ignoredRepos.enumerated()), id: \.offset) { index, repo in + HStack { + TextField("owner/repo", text: Binding( + get: { repo }, + set: { newValue in + var updated = user + updated.ignoredRepos[index] = newValue + monitoredUsersService.updateUser(updated) + } + )) + .textFieldStyle(.roundedBorder) + .font(.body) + Button(action: { + var updated = user + updated.ignoredRepos.remove(at: index) + monitoredUsersService.updateUser(updated) + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + } + + HStack { + TextField("owner/repo", text: $newIgnoredRepo) + .textFieldStyle(.roundedBorder) + .onSubmit { addIgnoredRepo(for: user) } + Button("Add") { addIgnoredRepo(for: user) } + .disabled(newIgnoredRepo.isEmpty) + } + } + + Divider() + + // Ignored CI Checks + VStack(alignment: .leading, spacing: 8) { + Text("Ignored CI Checks") + .font(.headline) + Text("Failing checks matching these patterns are excluded from status calculation. Supports * wildcard.") + .font(.caption) + .foregroundColor(.secondary) + + ForEach(Array(user.ignoredChecks.enumerated()), id: \.element.id) { index, rule in + HStack { + TextField("Pattern", text: Binding( + get: { rule.pattern }, + set: { newValue in + var updated = user + updated.ignoredChecks[index].pattern = newValue + monitoredUsersService.updateUser(updated) + } + )) + .textFieldStyle(.roundedBorder) + .font(.body) + TextField("Repo", text: Binding( + get: { rule.repository }, + set: { newValue in + var updated = user + updated.ignoredChecks[index].repository = newValue.isEmpty ? "*" : newValue + monitoredUsersService.updateUser(updated) + } + )) + .textFieldStyle(.roundedBorder) + .font(.caption) + .frame(width: 140) + Button(action: { + var updated = user + updated.ignoredChecks.remove(at: index) + monitoredUsersService.updateUser(updated) + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + } + + HStack { + TextField("Pattern (e.g. codecov/*)", text: $newCheckPattern) + .textFieldStyle(.roundedBorder) + TextField("Repo (* for all)", text: $newCheckRepo) + .textFieldStyle(.roundedBorder) + .frame(width: 140) + Button("Add") { addIgnoredCheck(for: user) } + .disabled(newCheckPattern.isEmpty) + } + } + } + .padding() + } + } + + private var globalRulesDetailView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("global_rules") + .font(.headline) + Text("These ignore rules apply to all monitored users. This item only exists in Settings.") + .font(.caption) + .foregroundColor(.secondary) + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Ignored Repositories") + .font(.headline) + Text("PRs from these repos won't be shown for any user.") + .font(.caption) + .foregroundColor(.secondary) + + ForEach(Array(globalIgnoredRepos.enumerated()), id: \.offset) { index, repo in + HStack { + TextField("owner/repo", text: Binding( + get: { globalIgnoredRepos[index] }, + set: { newValue in + var updated = globalIgnoredRepos + updated[index] = newValue + globalIgnoredRepos = updated + } + )) + .textFieldStyle(.roundedBorder) + .font(.body) + Button(action: { + var updated = globalIgnoredRepos + updated.remove(at: index) + globalIgnoredRepos = updated + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + } + + HStack { + TextField("owner/repo", text: $newIgnoredRepo) + .textFieldStyle(.roundedBorder) + .onSubmit { addGlobalIgnoredRepo() } + Button("Add") { addGlobalIgnoredRepo() } + .disabled(newIgnoredRepo.isEmpty) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Ignored CI Checks") + .font(.headline) + Text("Failing checks matching these patterns are excluded from status calculation for all users. Supports * wildcard.") + .font(.caption) + .foregroundColor(.secondary) + + ForEach(Array(globalIgnoredChecks.enumerated()), id: \.element.id) { index, rule in + HStack { + TextField("Pattern", text: Binding( + get: { rule.pattern }, + set: { newValue in + var updated = globalIgnoredChecks + updated[index].pattern = newValue + globalIgnoredChecks = updated + } + )) + .textFieldStyle(.roundedBorder) + .font(.body) + TextField("Repo", text: Binding( + get: { rule.repository }, + set: { newValue in + var updated = globalIgnoredChecks + updated[index].repository = newValue.isEmpty ? "*" : newValue + globalIgnoredChecks = updated + } + )) + .textFieldStyle(.roundedBorder) + .font(.caption) + .frame(width: 140) + Button(action: { + var updated = globalIgnoredChecks + updated.remove(at: index) + globalIgnoredChecks = updated + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + } + + HStack { + TextField("Pattern (e.g. codecov/*)", text: $newCheckPattern) + .textFieldStyle(.roundedBorder) + TextField("Repo (* for all)", text: $newCheckRepo) + .textFieldStyle(.roundedBorder) + .frame(width: 140) + Button("Add") { addGlobalIgnoredCheck() } + .disabled(newCheckPattern.isEmpty) + } + } + } + .padding() + } + } + + private func submitAddUser() { + let username = newUsername.trimmingCharacters(in: .whitespacesAndNewlines) + guard !username.isEmpty else { return } + monitoredUsersService.addUser( + username: username, + displayName: newDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) + ) + newUsername = "" + newDisplayName = "" + showAddUser = false + } + + private var addUserSheet: some View { + VStack(spacing: 12) { + Text("Add Monitored User") + .font(.headline) + TextField("GitHub username", text: $newUsername) + .textFieldStyle(.roundedBorder) + .onSubmit { submitAddUser() } + TextField("Display name (optional)", text: $newDisplayName) + .textFieldStyle(.roundedBorder) + .onSubmit { submitAddUser() } + HStack { + Spacer() + Button("Cancel") { showAddUser = false } + Button("Add") { submitAddUser() } + .disabled(newUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .buttonStyle(.borderedProminent) + } + } + .padding() + .frame(width: 300) + } + + private func addIgnoredRepo(for user: MonitoredUser) { + let repo = newIgnoredRepo.trimmingCharacters(in: .whitespacesAndNewlines) + guard !repo.isEmpty else { return } + var updated = user + guard !updated.ignoredRepos.contains(repo) else { newIgnoredRepo = ""; return } + updated.ignoredRepos.append(repo) + monitoredUsersService.updateUser(updated) + newIgnoredRepo = "" + } + + private func addIgnoredCheck(for user: MonitoredUser) { + let pattern = newCheckPattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !pattern.isEmpty else { return } + var updated = user + let repoScope = newCheckRepo.trimmingCharacters(in: .whitespacesAndNewlines) + let rule = IgnoredCheckRule(pattern: pattern, repository: repoScope.isEmpty ? "*" : repoScope) + updated.ignoredChecks.append(rule) + monitoredUsersService.updateUser(updated) + newCheckPattern = "" + newCheckRepo = "*" + } + + private func addGlobalIgnoredRepo() { + let repo = newIgnoredRepo.trimmingCharacters(in: .whitespacesAndNewlines) + guard !repo.isEmpty else { return } + var updated = globalIgnoredRepos + guard !updated.contains(repo) else { newIgnoredRepo = ""; return } + updated.append(repo) + globalIgnoredRepos = updated + newIgnoredRepo = "" + } + + private func addGlobalIgnoredCheck() { + let pattern = newCheckPattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !pattern.isEmpty else { return } + var updated = globalIgnoredChecks + let repoScope = newCheckRepo.trimmingCharacters(in: .whitespacesAndNewlines) + let rule = IgnoredCheckRule(pattern: pattern, repository: repoScope.isEmpty ? "*" : repoScope) + updated.append(rule) + globalIgnoredChecks = updated + newCheckPattern = "" + newCheckRepo = "*" + } + + private func decodeGlobalRepos() -> [String] { + guard !globalIgnoredReposData.isEmpty, + let repos = try? JSONDecoder().decode([String].self, from: globalIgnoredReposData) else { + return [] + } + return repos + } + + private func saveGlobalRepos(_ repos: [String]) { + globalIgnoredReposData = (try? JSONEncoder().encode(repos)) ?? Data() + monitoredUsersService.notifyConfigurationChanged() + } + + private func decodeGlobalChecks() -> [IgnoredCheckRule] { + guard !globalIgnoredChecksData.isEmpty, + let rules = try? JSONDecoder().decode([IgnoredCheckRule].self, from: globalIgnoredChecksData) else { + return [] + } + return rules + } + + private func saveGlobalChecks(_ rules: [IgnoredCheckRule]) { + globalIgnoredChecksData = (try? JSONEncoder().encode(rules)) ?? Data() + monitoredUsersService.notifyConfigurationChanged() + } + + // MARK: - Notifications + private var notificationSettings: some View { Form { + refreshLogSection + Section { Toggle("Show notifications", isOn: $showNotifications) .help("Display macOS notifications when watched builds complete") @@ -189,6 +745,44 @@ struct SettingsView: View { .formStyle(.grouped) } + @StateObject private var refreshLogger = RefreshLogger.shared + + private var refreshLogSection: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Refresh Log") + .font(.headline) + Spacer() + Button("Clear") { + refreshLogger.clear() + } + .font(.caption) + } + + ScrollViewReader { proxy in + ScrollView { + Text(refreshLogger.logs.isEmpty ? "No logs yet. Logs appear after a refresh." : refreshLogger.logs) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(refreshLogger.logs.isEmpty ? .secondary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .id("logBottom") + } + .frame(height: 150) + .background(Color(nsColor: .textBackgroundColor)) + .cornerRadius(4) + .onChange(of: refreshLogger.logs) { _, _ in + proxy.scrollTo("logBottom", anchor: .bottom) + } + } + } + .padding(.vertical, 8) + } + } + + // MARK: - About + private var aboutView: some View { VStack(spacing: 20) { Image(systemName: "lizard") @@ -249,7 +843,6 @@ struct SettingsView: View { } } -// Preview #if DEBUG struct SettingsView_Previews: PreviewProvider { static var previews: some View {