From 086162e66e3fdc5d6e935c1b93c412e2b5790e2f Mon Sep 17 00:00:00 2001 From: eonist <30n1st@gmail.com> Date: Fri, 8 May 2026 12:28:15 +0200 Subject: [PATCH 01/79] feat: Phase 1 - add PieProgressView component (#297) --- Sources/RunnerBar/PieProgressView.swift | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 Sources/RunnerBar/PieProgressView.swift diff --git a/Sources/RunnerBar/PieProgressView.swift b/Sources/RunnerBar/PieProgressView.swift new file mode 100644 index 00000000..36f214bd --- /dev/null +++ b/Sources/RunnerBar/PieProgressView.swift @@ -0,0 +1,36 @@ +import SwiftUI + +// MARK: - PieProgressView + +/// A small circular progress indicator that renders as a radial pie fill. +/// +/// Visual states: +/// - `progress == 0.0` → empty circle outline only +/// - `0.0 < progress < 1.0` → partial arc from 12 o'clock clockwise (◔ ◑ ◕) +/// - `progress == 1.0` → solid filled circle (●) +/// +/// Used in action rows (size: 8) and inline ↳ child job rows (size: 7). +struct PieProgressView: View { + /// Completion fraction from 0.0 to 1.0. + let progress: Double + /// Status-driven color (green / yellow / red / gray). + let color: Color + /// Diameter in points. Defaults to 8 (main action row size). + var size: CGFloat = 8 + + var body: some View { + ZStack { + Circle() + .stroke(color.opacity(0.25), lineWidth: size * 0.25) + if progress >= 1.0 { + Circle().fill(color) + } else if progress > 0 { + Circle() + .trim(from: 0, to: CGFloat(progress)) + .rotation(.degrees(-90)) + .stroke(color, style: StrokeStyle(lineWidth: size * 0.25, lineCap: .round)) + } + } + .frame(width: size, height: size) + } +} From b05078bb9294eca6f9909785116a9adf61b532a0 Mon Sep 17 00:00:00 2001 From: eonist <30n1st@gmail.com> Date: Fri, 8 May 2026 12:38:08 +0200 Subject: [PATCH 02/79] feat: Phase 2 - combined stats header, remove System section + Quit footer (#299) --- Sources/RunnerBar/PopoverMainView.swift | 55 +++++++++---------------- Sources/RunnerBar/SystemStatsView.swift | 18 ++++++-- 2 files changed, 35 insertions(+), 38 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index c0e6067e..0a875958 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -14,7 +14,6 @@ import SwiftUI // RULE 4: NEVER use .fixedSize() on any container. // RULE 5: RunnerStoreObservable.reload() uses withAnimation(nil). -// swiftlint:disable type_body_length /// Root popover view. Shows system stats, action groups, active jobs, runners, and scope settings. struct PopoverMainView: View { /// The observable that bridges RunnerStore state into SwiftUI. @@ -31,34 +30,34 @@ struct PopoverMainView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - // ── Header - HStack { - Text("RunnerBar v0.34") // ⚠️ bump on every commit - .font(.headline).foregroundColor(.secondary) + // ── Header: stats + optional auth badge + gear + close (Phase 2 / #299) + HStack(spacing: 6) { + SystemStatsView(stats: systemStats.stats).statsContent Spacer() + // Show orange dot next to gear when not authenticated + if !isAuthenticated { + Button(action: signInWithGitHub) { + Circle().fill(Color.orange).frame(width: 7, height: 7) + } + .buttonStyle(.plain) + .help("Not authenticated — tap to set up a GitHub token") + } Button(action: onSelectSettings) { Image(systemName: "gearshape") - .font(.system(size: 14)) + .font(.system(size: 13)) .foregroundColor(.secondary) } .buttonStyle(.plain) .help("Settings") - .padding(.trailing, 4) - if isAuthenticated { - HStack(spacing: 4) { - Circle().fill(Color.green).frame(width: 8, height: 8) - Text("Authenticated").font(.caption).foregroundColor(.secondary) - } - } else { - Button(action: signInWithGitHub) { - HStack(spacing: 4) { - Circle().fill(Color.orange).frame(width: 8, height: 8) - Text("Sign in with GitHub").font(.caption).foregroundColor(.orange) - } - }.buttonStyle(.plain) - } + Button(action: { NSApplication.shared.terminate(nil) }, label: { + Image(systemName: "xmark") + .font(.system(size: 11)) + .foregroundColor(.secondary) + }) + .buttonStyle(.plain) + .help("Quit RunnerBar") } - .padding(.horizontal, 12).padding(.top, 12).padding(.bottom, 8) + .padding(.horizontal, 12).padding(.vertical, 6) Divider() if store.isRateLimited { @@ -72,13 +71,6 @@ struct PopoverMainView: View { Divider() } - // ── System - Text("System") - .font(.caption).foregroundColor(.secondary) - .padding(.horizontal, 12).padding(.top, 8).padding(.bottom, 2) - SystemStatsView(stats: systemStats.stats) - Divider() - // ── Actions Text("Actions") .font(.caption).foregroundColor(.secondary) @@ -162,12 +154,6 @@ struct PopoverMainView: View { } .padding(.bottom, 6) } - Divider() - Button(action: { NSApplication.shared.terminate(nil) }, label: { - Text("Quit RunnerBar").font(.system(size: 12)).foregroundColor(.secondary) - }) - .buttonStyle(.plain) - .padding(.horizontal, 12).padding(.vertical, 6) } .frame(idealWidth: 420, maxWidth: .infinity, alignment: .top) .onAppear { @@ -263,4 +249,3 @@ struct PopoverMainView: View { NSWorkspace.shared.open(url) } } -// swiftlint:enable type_body_length diff --git a/Sources/RunnerBar/SystemStatsView.swift b/Sources/RunnerBar/SystemStatsView.swift index be28d981..13b10c11 100644 --- a/Sources/RunnerBar/SystemStatsView.swift +++ b/Sources/RunnerBar/SystemStatsView.swift @@ -37,7 +37,14 @@ struct SystemStatsView: View { /// Injected snapshot — updated every 2 s by SystemStatsViewModel. let stats: SystemStats - var body: some View { + // ⚠️ REGRESSION GUARD: .lineLimit(1) on statsContent is LOAD-BEARING. + // Without it, the DISK label can wrap and break fittingSize popover height. + // Do NOT remove .lineLimit(1) from statsContent. + + /// Inner content — usable inline without double-padding. + /// When embedding in a combined header HStack (Phase 2 / #299), use + /// this directly and apply padding on the outer container. + var statsContent: some View { HStack(spacing: 6) { cpuSegment memSegment @@ -46,10 +53,15 @@ struct SystemStatsView: View { // CRITICAL: .lineLimit(1) prevents the DISK label from wrapping and // breaking fittingSize-based popover height in AppDelegate. .lineLimit(1) + } + + /// Standalone usage (backward compat) — wraps statsContent with row padding. + var body: some View { + statsContent // RULE 2 (from PopoverMainView): all rows use .padding(.horizontal, 12). // Do not change this without changing every other row's padding too. - .padding(.horizontal, 12) - .padding(.vertical, 4) + .padding(.horizontal, 12) + .padding(.vertical, 4) } // ── CPU segment ────────────────────────────────────────────────────────────── From dce66a3365f4d9b247ceed46a8febc1dc39c9c70 Mon Sep 17 00:00:00 2001 From: eonist <30n1st@gmail.com> Date: Fri, 8 May 2026 12:41:02 +0200 Subject: [PATCH 03/79] feat: Phase 3 - action row redesign + startedAgo (#302) --- Sources/RunnerBar/ActionGroup.swift | 13 +++++ Sources/RunnerBar/PopoverMainView.swift | 72 ++++++++++++++++++++----- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/Sources/RunnerBar/ActionGroup.swift b/Sources/RunnerBar/ActionGroup.swift index dd5cd44b..cc267a1f 100644 --- a/Sources/RunnerBar/ActionGroup.swift +++ b/Sources/RunnerBar/ActionGroup.swift @@ -121,6 +121,19 @@ struct ActionGroup: Identifiable { return "—" } + /// How long ago the group started, as a short human string, e.g. "3m ago", "1h ago". + /// Uses `firstJobStartedAt` when available, falls back to `createdAt`. + /// Returns "—" if neither timestamp is available. + var startedAgo: String { + let ref = firstJobStartedAt ?? createdAt + guard let ref = ref else { return "—" } + let sec = Int(Date().timeIntervalSince(ref)) + guard sec >= 0 else { return "—" } + if sec < 60 { return "\(sec)s ago" } + if sec < 3600 { return "\(sec / 60)m ago" } + return "\(sec / 3600)h ago" + } + /// Elapsed time derived from min(job.startedAt) → max(job.completedAt). var elapsed: String { if let start = firstJobStartedAt { diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index 0a875958..fef3fb8c 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -14,6 +14,7 @@ import SwiftUI // RULE 4: NEVER use .fixedSize() on any container. // RULE 5: RunnerStoreObservable.reload() uses withAnimation(nil). +// swiftlint:disable type_body_length /// Root popover view. Shows system stats, action groups, active jobs, runners, and scope settings. struct PopoverMainView: View { /// The observable that bridges RunnerStore state into SwiftUI. @@ -80,33 +81,48 @@ struct PopoverMainView: View { .font(.caption).foregroundColor(.secondary) .padding(.horizontal, 12).padding(.vertical, 4) } else { + // Phase 3 (#302): redesigned action row + // Layout: [pie] SHA title····· startedAgo elapsed jobs status › ForEach(store.actions.prefix(5)) { actionGroup in Button(action: { onSelectAction(actionGroup) }, label: { - HStack(spacing: 8) { - actionDot(for: actionGroup) + HStack(spacing: 6) { + // Pie progress dot + PieProgressView( + progress: actionGroup.jobsTotal > 0 + ? Double(actionGroup.jobsDone) / Double(actionGroup.jobsTotal) + : (actionGroup.groupStatus == .completed ? 1.0 : 0.0), + color: actionDotColor(for: actionGroup) + ) + // SHA / PR label Text(actionGroup.label) .font(.caption.monospacedDigit()) .foregroundColor(.secondary) .lineLimit(1) - .frame(width: 52, alignment: .leading) + .frame(width: 46, alignment: .leading) + // Commit / PR title Text(actionGroup.title) .font(.system(size: 12)) .foregroundColor(actionGroup.isDimmed ? .secondary : .primary) .lineLimit(1).truncationMode(.tail) Spacer() - if actionGroup.groupStatus == .inProgress - || actionGroup.groupStatus == .queued { - Text(actionGroup.currentJobName) - .font(.caption).foregroundColor(.secondary) - .lineLimit(1).truncationMode(.tail) - .frame(minWidth: 0, maxWidth: 80, alignment: .trailing) - } - Text(actionGroup.jobProgress) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 30, alignment: .trailing) + // Started-ago timestamp + Text(actionGroup.startedAgo) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + .frame(width: 44, alignment: .trailing) + // Elapsed MM:SS Text(actionGroup.elapsed) .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 40, alignment: .trailing) + .frame(width: 36, alignment: .trailing) + // Job progress fraction + Text(actionGroup.jobProgress) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 28, alignment: .trailing) + // Status text + Text(actionStatusLabel(for: actionGroup)) + .font(.caption) + .foregroundColor(actionStatusColor(for: actionGroup)) + .frame(width: 60, alignment: .trailing) Image(systemName: "chevron.right") .font(.caption2).foregroundColor(.secondary) } @@ -180,6 +196,33 @@ struct PopoverMainView: View { .frame(width: 8, height: 8) } + /// Human-readable status label for an action group. + private func actionStatusLabel(for group: ActionGroup) -> String { + switch group.groupStatus { + case .inProgress: return "Running" + case .queued: return "Queued" + case .completed: + switch group.conclusion { + case "success": return "Success" + case "failure": return "Failed" + case "cancelled": return "Cancelled" + case "skipped": return "Skipped" + default: return "Done" + } + } + } + + /// Foreground color for an action group's status label. + private func actionStatusColor(for group: ActionGroup) -> Color { + switch group.groupStatus { + case .inProgress: return .yellow + case .queued: return .blue + case .completed: + if group.isDimmed { return .secondary } + return group.conclusion == "success" ? .green : .red + } + } + /// Color for an action group's status dot. private func actionDotColor(for group: ActionGroup) -> Color { switch group.groupStatus { @@ -249,3 +292,4 @@ struct PopoverMainView: View { NSWorkspace.shared.open(url) } } +// swiftlint:enable type_body_length From 119cae76f539c0ebb8b35e7839d3922ff13bd1a7 Mon Sep 17 00:00:00 2001 From: eonist <30n1st@gmail.com> Date: Fri, 8 May 2026 12:43:22 +0200 Subject: [PATCH 04/79] feat: Phase 4 - inline job rows, remove Active Jobs section (#304) --- Sources/RunnerBar/PopoverMainView.swift | 74 ++++++++++++------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index fef3fb8c..70b50668 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -129,44 +129,44 @@ struct PopoverMainView: View { .padding(.horizontal, 12).padding(.vertical, 3) }) .buttonStyle(.plain) - } - .padding(.bottom, 6) - } - Divider() - - // ── Active Jobs - Text("Active Jobs") - .font(.caption).foregroundColor(.secondary) - .padding(.horizontal, 12).padding(.top, 8).padding(.bottom, 2) - if store.jobs.isEmpty { - Text("No active jobs") - .font(.caption).foregroundColor(.secondary) - .padding(.horizontal, 12).padding(.vertical, 4) - } else { - ForEach(store.jobs.prefix(3)) { job in - Button(action: { onSelectJob(job) }, label: { - HStack(spacing: 8) { - jobDot(for: job) - Text(job.name) - .font(.system(size: 12)) - .foregroundColor(job.isDimmed ? .secondary : .primary) - .lineLimit(1).truncationMode(.tail) - Spacer() - Text(job.isDimmed ? conclusionLabel(for: job) : jobStatusLabel(for: job)) - .font(.caption) - .foregroundColor( - job.isDimmed ? conclusionColor(for: job) : jobStatusColor(for: job) - ) - .frame(width: 76, alignment: .trailing) - Text(job.elapsed) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 40, alignment: .trailing) - Image(systemName: "chevron.right") - .font(.caption2).foregroundColor(.secondary) + // Phase 4 (#304): inline ↳ job rows for in-progress groups + if actionGroup.groupStatus == .inProgress || actionGroup.groupStatus == .queued { + ForEach(actionGroup.jobs.filter { + $0.status == "in_progress" || $0.status == "queued" + }.prefix(3)) { job in + Button(action: { onSelectJob(job) }, label: { + HStack(spacing: 6) { + // indent + Text("↳") + .font(.caption2) + .foregroundColor(.secondary) + .padding(.leading, 14) + PieProgressView( + progress: job.status == "in_progress" ? 0.5 : 0.0, + color: jobDotColor(for: job), + size: 7 + ) + Text(job.name) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1).truncationMode(.tail) + Spacer() + Text(job.status == "in_progress" ? "Running" : "Queued") + .font(.caption) + .foregroundColor(job.status == "in_progress" ? .yellow : .blue) + .frame(width: 46, alignment: .trailing) + Text(job.elapsed) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + .frame(width: 36, alignment: .trailing) + Image(systemName: "chevron.right") + .font(.caption2).foregroundColor(.secondary) + } + .padding(.horizontal, 12).padding(.vertical, 2) + }) + .buttonStyle(.plain) } - .padding(.horizontal, 12).padding(.vertical, 3) - }) - .buttonStyle(.plain) + } } .padding(.bottom, 6) } From 93f269550abaa74477f84ffe899f49679a89afdc Mon Sep 17 00:00:00 2001 From: eonist <30n1st@gmail.com> Date: Fri, 8 May 2026 12:44:19 +0200 Subject: [PATCH 05/79] feat: Phase 5 - ScrollView + Load more pagination (#305) --- Sources/RunnerBar/PopoverMainView.swift | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index 70b50668..379924e1 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -28,6 +28,8 @@ struct PopoverMainView: View { @State private var isAuthenticated = (githubToken() != nil) @StateObject private var systemStats = SystemStatsViewModel() + /// Number of action groups visible. Starts at 10, incremented by 10 on "Load more". + @State private var visibleCount: Int = 10 var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -81,9 +83,12 @@ struct PopoverMainView: View { .font(.caption).foregroundColor(.secondary) .padding(.horizontal, 12).padding(.vertical, 4) } else { + // Phase 5 (#305): ScrollView + visibleCount pagination + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 0) { // Phase 3 (#302): redesigned action row // Layout: [pie] SHA title····· startedAgo elapsed jobs status › - ForEach(store.actions.prefix(5)) { actionGroup in + ForEach(store.actions.prefix(visibleCount)) { actionGroup in Button(action: { onSelectAction(actionGroup) }, label: { HStack(spacing: 6) { // Pie progress dot @@ -168,6 +173,20 @@ struct PopoverMainView: View { } } } + } // ForEach end + // "Load 10 more" button — only shown when more groups exist + if store.actions.count > visibleCount { + Button(action: { visibleCount += 10 }, label: { + Text("Load \(min(10, store.actions.count - visibleCount)) more") + .font(.caption) + .foregroundColor(.secondary) + }) + .buttonStyle(.plain) + .padding(.horizontal, 12).padding(.vertical, 6) + } + } // LazyVStack end + } // ScrollView end + .frame(maxHeight: 400) .padding(.bottom, 6) } } From 4556f24b0eca52d674b886e44f7c60760c37f6f1 Mon Sep 17 00:00:00 2001 From: eonist <30n1st@gmail.com> Date: Fri, 8 May 2026 12:50:00 +0200 Subject: [PATCH 06/79] feat: Phase 6 - conditional runners sub-section (#307) --- Sources/RunnerBar/PopoverMainView.swift | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index 379924e1..f3819594 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -30,6 +30,8 @@ struct PopoverMainView: View { @StateObject private var systemStats = SystemStatsViewModel() /// Number of action groups visible. Starts at 10, incremented by 10 on "Load more". @State private var visibleCount: Int = 10 + /// Local runner store — drives Phase 6 runners sub-section. + @ObservedObject private var localRunners = LocalRunnerStore.shared var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -189,11 +191,38 @@ struct PopoverMainView: View { .frame(maxHeight: 400) .padding(.bottom, 6) } + + // ── Phase 6 (#307): Runners — only shown when ≥1 runner is active + let activeRunners = localRunners.runners.filter { $0.isRunning } + if !activeRunners.isEmpty { + Divider() + Text("Runners") + .font(.caption).foregroundColor(.secondary) + .padding(.horizontal, 12).padding(.top, 8).padding(.bottom, 2) + ForEach(activeRunners) { runner in + HStack(spacing: 6) { + Circle() + .fill(runnerDotColor(for: runner)) + .frame(width: 7, height: 7) + Text(runner.runnerName) + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineLimit(1).truncationMode(.tail) + Spacer() + Text(runner.statusDescription) + .font(.caption) + .foregroundColor(runnerDotColor(for: runner)) + } + .padding(.horizontal, 12).padding(.vertical, 3) + } + .padding(.bottom, 6) + } } .frame(idealWidth: 420, maxWidth: .infinity, alignment: .top) .onAppear { isAuthenticated = (githubToken() != nil) systemStats.start() + Task { await localRunners.refresh() } } } @@ -253,6 +282,16 @@ struct PopoverMainView: View { } } + /// Dot color for a local runner based on its status. + private func runnerDotColor(for runner: RunnerModel) -> Color { + switch runner.statusColor { + case .running: return .green + case .busy: return .yellow + case .idle: return .secondary + case .offline: return .red + } + } + /// Color for a job's status dot. private func jobDotColor(for job: ActiveJob) -> Color { switch job.status { From 70189fe0bb0207d039df15f908fd758332b9b9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 13:38:49 +0200 Subject: [PATCH 07/79] =?UTF-8?q?fix:=20apply=20review=20findings=20#1?= =?UTF-8?q?=E2=80=93#11=20from=20PR=20#311=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 Bugs: - #1 actionStatusLabel → uppercase per spec (IN PROGRESS / SUCCESS / FAILED / CANCELED) - #2 Child ↳ row status text → uppercase (IN PROGRESS / QUEUED) - #3 [×] button → NSApplication.shared.hide(nil) instead of terminate(nil) - #4 Child PieProgressView progress → real step completion fraction 🟡 Polish / Spec: - #5 Fix ScrollView/LazyVStack/ForEach indentation alignment - #6 Simplify Load more label to static "Load 10 more actions…" - #7 Remove Text("Actions") section label - #8 Runner rows → Button wrapper + chevron.right - #9 Remove Text("Runners") section label - #11 Add .onDisappear { systemStats.stop() } 📝 Docs: - #10 Fix RunnerStoreObservable comment: "capped at 5" → "capped at 10" --- Sources/RunnerBar/PopoverMainView.swift | 259 +++++++++--------- Sources/RunnerBar/RunnerStoreObservable.swift | 2 +- 2 files changed, 138 insertions(+), 123 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index f3819594..d005a68a 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -54,13 +54,14 @@ struct PopoverMainView: View { } .buttonStyle(.plain) .help("Settings") - Button(action: { NSApplication.shared.terminate(nil) }, label: { + // fix #3 (#311): hide popover instead of terminating the app + Button(action: { NSApplication.shared.hide(nil) }, label: { Image(systemName: "xmark") .font(.system(size: 11)) .foregroundColor(.secondary) }) .buttonStyle(.plain) - .help("Quit RunnerBar") + .help("Hide RunnerBar") } .padding(.horizontal, 12).padding(.vertical, 6) Divider() @@ -76,10 +77,7 @@ struct PopoverMainView: View { Divider() } - // ── Actions - Text("Actions") - .font(.caption).foregroundColor(.secondary) - .padding(.horizontal, 12).padding(.top, 8).padding(.bottom, 2) + // ── Actions (no section label per spec #178) if store.actions.isEmpty { Text("No recent actions") .font(.caption).foregroundColor(.secondary) @@ -88,132 +86,144 @@ struct PopoverMainView: View { // Phase 5 (#305): ScrollView + visibleCount pagination ScrollView(.vertical, showsIndicators: false) { LazyVStack(spacing: 0) { - // Phase 3 (#302): redesigned action row - // Layout: [pie] SHA title····· startedAgo elapsed jobs status › - ForEach(store.actions.prefix(visibleCount)) { actionGroup in - Button(action: { onSelectAction(actionGroup) }, label: { - HStack(spacing: 6) { - // Pie progress dot - PieProgressView( - progress: actionGroup.jobsTotal > 0 - ? Double(actionGroup.jobsDone) / Double(actionGroup.jobsTotal) - : (actionGroup.groupStatus == .completed ? 1.0 : 0.0), - color: actionDotColor(for: actionGroup) - ) - // SHA / PR label - Text(actionGroup.label) - .font(.caption.monospacedDigit()) - .foregroundColor(.secondary) - .lineLimit(1) - .frame(width: 46, alignment: .leading) - // Commit / PR title - Text(actionGroup.title) - .font(.system(size: 12)) - .foregroundColor(actionGroup.isDimmed ? .secondary : .primary) - .lineLimit(1).truncationMode(.tail) - Spacer() - // Started-ago timestamp - Text(actionGroup.startedAgo) - .font(.caption.monospacedDigit()) - .foregroundColor(.secondary) - .frame(width: 44, alignment: .trailing) - // Elapsed MM:SS - Text(actionGroup.elapsed) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 36, alignment: .trailing) - // Job progress fraction - Text(actionGroup.jobProgress) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 28, alignment: .trailing) - // Status text - Text(actionStatusLabel(for: actionGroup)) - .font(.caption) - .foregroundColor(actionStatusColor(for: actionGroup)) - .frame(width: 60, alignment: .trailing) - Image(systemName: "chevron.right") - .font(.caption2).foregroundColor(.secondary) - } - .padding(.horizontal, 12).padding(.vertical, 3) - }) - .buttonStyle(.plain) - // Phase 4 (#304): inline ↳ job rows for in-progress groups - if actionGroup.groupStatus == .inProgress || actionGroup.groupStatus == .queued { - ForEach(actionGroup.jobs.filter { - $0.status == "in_progress" || $0.status == "queued" - }.prefix(3)) { job in - Button(action: { onSelectJob(job) }, label: { + // Phase 3 (#302): redesigned action row + // Layout: [pie] SHA title····· startedAgo elapsed jobs status › + ForEach(store.actions.prefix(visibleCount)) { actionGroup in + Button(action: { onSelectAction(actionGroup) }, label: { HStack(spacing: 6) { - // indent - Text("↳") - .font(.caption2) - .foregroundColor(.secondary) - .padding(.leading, 14) + // Pie progress dot PieProgressView( - progress: job.status == "in_progress" ? 0.5 : 0.0, - color: jobDotColor(for: job), - size: 7 + progress: actionGroup.jobsTotal > 0 + ? Double(actionGroup.jobsDone) / Double(actionGroup.jobsTotal) + : (actionGroup.groupStatus == .completed ? 1.0 : 0.0), + color: actionDotColor(for: actionGroup) ) - Text(job.name) - .font(.caption) + // SHA / PR label + Text(actionGroup.label) + .font(.caption.monospacedDigit()) .foregroundColor(.secondary) + .lineLimit(1) + .frame(width: 46, alignment: .leading) + // Commit / PR title + Text(actionGroup.title) + .font(.system(size: 12)) + .foregroundColor(actionGroup.isDimmed ? .secondary : .primary) .lineLimit(1).truncationMode(.tail) Spacer() - Text(job.status == "in_progress" ? "Running" : "Queued") - .font(.caption) - .foregroundColor(job.status == "in_progress" ? .yellow : .blue) - .frame(width: 46, alignment: .trailing) - Text(job.elapsed) + // Started-ago timestamp + Text(actionGroup.startedAgo) .font(.caption.monospacedDigit()) .foregroundColor(.secondary) + .frame(width: 44, alignment: .trailing) + // Elapsed MM:SS + Text(actionGroup.elapsed) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) .frame(width: 36, alignment: .trailing) + // Job progress fraction + Text(actionGroup.jobProgress) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 28, alignment: .trailing) + // Status text — uppercase per spec #178 #302 #285 (fix #1) + Text(actionStatusLabel(for: actionGroup)) + .font(.caption) + .foregroundColor(actionStatusColor(for: actionGroup)) + .frame(width: 60, alignment: .trailing) Image(systemName: "chevron.right") .font(.caption2).foregroundColor(.secondary) } - .padding(.horizontal, 12).padding(.vertical, 2) + .padding(.horizontal, 12).padding(.vertical, 3) + }) + .buttonStyle(.plain) + // Phase 4 (#304): inline ↳ job rows for in-progress groups + if actionGroup.groupStatus == .inProgress || actionGroup.groupStatus == .queued { + ForEach(actionGroup.jobs.filter { + $0.status == "in_progress" || $0.status == "queued" + }.prefix(3)) { job in + Button(action: { onSelectJob(job) }, label: { + HStack(spacing: 6) { + // indent + Text("↳") + .font(.caption2) + .foregroundColor(.secondary) + .padding(.leading, 14) + // fix #4 (#311): real step completion fraction + let stepProgress: Double = { + let total = job.steps.count + guard total > 0 else { + return job.status == "in_progress" ? 0.5 : 0.0 + } + let done = job.steps.filter { $0.conclusion != nil }.count + return Double(done) / Double(total) + }() + PieProgressView( + progress: stepProgress, + color: jobDotColor(for: job), + size: 7 + ) + Text(job.name) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1).truncationMode(.tail) + Spacer() + // fix #2 (#311): uppercase per spec + Text(job.status == "in_progress" ? "IN PROGRESS" : "QUEUED") + .font(.caption) + .foregroundColor(job.status == "in_progress" ? .yellow : .blue) + .frame(width: 60, alignment: .trailing) + Text(job.elapsed) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + .frame(width: 36, alignment: .trailing) + Image(systemName: "chevron.right") + .font(.caption2).foregroundColor(.secondary) + } + .padding(.horizontal, 12).padding(.vertical, 2) + }) + .buttonStyle(.plain) + } + } + } + // fix #6 (#311): static label — dynamic count can be wrong when store paginates + if store.actions.count > visibleCount { + Button(action: { visibleCount += 10 }, label: { + Text("Load 10 more actions…") + .font(.caption) + .foregroundColor(.secondary) }) .buttonStyle(.plain) + .padding(.horizontal, 12).padding(.vertical, 6) } } } - } // ForEach end - // "Load 10 more" button — only shown when more groups exist - if store.actions.count > visibleCount { - Button(action: { visibleCount += 10 }, label: { - Text("Load \(min(10, store.actions.count - visibleCount)) more") - .font(.caption) - .foregroundColor(.secondary) - }) - .buttonStyle(.plain) - .padding(.horizontal, 12).padding(.vertical, 6) - } - } // LazyVStack end - } // ScrollView end .frame(maxHeight: 400) .padding(.bottom, 6) } - // ── Phase 6 (#307): Runners — only shown when ≥1 runner is active + // ── Phase 6 (#307): Runners — only shown when ≥1 runner is active (no section label per spec) let activeRunners = localRunners.runners.filter { $0.isRunning } if !activeRunners.isEmpty { Divider() - Text("Runners") - .font(.caption).foregroundColor(.secondary) - .padding(.horizontal, 12).padding(.top, 8).padding(.bottom, 2) + // fix #8 (#311): runner rows are tappable with > chevron ForEach(activeRunners) { runner in - HStack(spacing: 6) { - Circle() - .fill(runnerDotColor(for: runner)) - .frame(width: 7, height: 7) - Text(runner.runnerName) - .font(.system(size: 12)) - .foregroundColor(.primary) - .lineLimit(1).truncationMode(.tail) - Spacer() - Text(runner.statusDescription) - .font(.caption) - .foregroundColor(runnerDotColor(for: runner)) - } - .padding(.horizontal, 12).padding(.vertical, 3) + Button(action: { /* onSelectRunner(runner) — no-op until runner detail view exists */ }, label: { + HStack(spacing: 6) { + Circle() + .fill(runnerDotColor(for: runner)) + .frame(width: 7, height: 7) + Text(runner.runnerName) + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineLimit(1).truncationMode(.tail) + Spacer() + Text(runner.statusDescription) + .font(.caption) + .foregroundColor(runnerDotColor(for: runner)) + Image(systemName: "chevron.right") + .font(.caption2).foregroundColor(.secondary) + } + .padding(.horizontal, 12).padding(.vertical, 3) + }) + .buttonStyle(.plain) } .padding(.bottom, 6) } @@ -224,6 +234,10 @@ struct PopoverMainView: View { systemStats.start() Task { await localRunners.refresh() } } + // fix #11 (#311): stop stats timer when popover is dismissed + .onDisappear { + systemStats.stop() + } } // MARK: - Helpers @@ -245,17 +259,18 @@ struct PopoverMainView: View { } /// Human-readable status label for an action group. + /// Returns uppercase strings per spec (#178 #302 #285). fix #1 (#311) private func actionStatusLabel(for group: ActionGroup) -> String { switch group.groupStatus { - case .inProgress: return "Running" - case .queued: return "Queued" + case .inProgress: return "IN PROGRESS" + case .queued: return "QUEUED" case .completed: switch group.conclusion { - case "success": return "Success" - case "failure": return "Failed" - case "cancelled": return "Cancelled" - case "skipped": return "Skipped" - default: return "Done" + case "success": return "SUCCESS" + case "failure": return "FAILED" + case "cancelled": return "CANCELED" + case "skipped": return "SKIPPED" + default: return "DONE" } } } @@ -304,9 +319,9 @@ struct PopoverMainView: View { /// Human-readable status label for a live job. private func jobStatusLabel(for job: ActiveJob) -> String { switch job.status { - case "in_progress": return "Running" - case "queued": return "Queued" - default: return job.status.capitalized + case "in_progress": return "IN PROGRESS" + case "queued": return "QUEUED" + default: return job.status.uppercased() } } @@ -322,11 +337,11 @@ struct PopoverMainView: View { /// Human-readable conclusion label for a completed/dimmed job. private func conclusionLabel(for job: ActiveJob) -> String { switch job.conclusion { - case "success": return "Success" - case "failure": return "Failed" - case "cancelled": return "Cancelled" - case "skipped": return "Skipped" - default: return job.conclusion?.capitalized ?? "Done" + case "success": return "SUCCESS" + case "failure": return "FAILED" + case "cancelled": return "CANCELED" + case "skipped": return "SKIPPED" + default: return job.conclusion?.uppercased() ?? "DONE" } } diff --git a/Sources/RunnerBar/RunnerStoreObservable.swift b/Sources/RunnerBar/RunnerStoreObservable.swift index f9cd3cf0..483838c6 100644 --- a/Sources/RunnerBar/RunnerStoreObservable.swift +++ b/Sources/RunnerBar/RunnerStoreObservable.swift @@ -9,7 +9,7 @@ import SwiftUI /// `reload()` is the ONE place where store state is copied into published properties. /// It always runs on the main thread and suppresses SwiftUI animations (ref #52 #54). final class RunnerStoreObservable: ObservableObject { - /// Action groups to display (live + recently completed, capped at 5). + /// Action groups to display (live + recently completed, capped at 10). @Published private(set) var actions: [ActionGroup] = [] /// Jobs to display (live + recently completed, capped at 3). @Published private(set) var jobs: [ActiveJob] = [] From 87fff9b06726b2e7570672315ef287c1ff44ac33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 13:42:24 +0200 Subject: [PATCH 08/79] fix: #314 remaining merit items #5 #6 #8 #9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #5 PieProgressView partial state: replace .stroke ring arc with Path filled wedge so partial progress renders as a true pie slice (◔ ◑ ◕) - #6 RunnerStoreObservable: add @MainActor for compile-time thread safety - #8 Auth dot: route to onSelectSettings instead of signInWithGitHub() - #9 Inline ↳ job rows: make passive (remove Button/onSelectJob/chevron) --- Sources/RunnerBar/PieProgressView.swift | 29 ++++- Sources/RunnerBar/PopoverMainView.swift | 105 ++++++++---------- Sources/RunnerBar/RunnerStoreObservable.swift | 4 + 3 files changed, 75 insertions(+), 63 deletions(-) diff --git a/Sources/RunnerBar/PieProgressView.swift b/Sources/RunnerBar/PieProgressView.swift index 36f214bd..f0137081 100644 --- a/Sources/RunnerBar/PieProgressView.swift +++ b/Sources/RunnerBar/PieProgressView.swift @@ -6,8 +6,8 @@ import SwiftUI /// /// Visual states: /// - `progress == 0.0` → empty circle outline only -/// - `0.0 < progress < 1.0` → partial arc from 12 o'clock clockwise (◔ ◑ ◕) -/// - `progress == 1.0` → solid filled circle (●) +/// - `0.0 < progress < 1.0` → partial filled wedge from 12 o'clock clockwise (◔ ◑ ◕) +/// - `progress >= 1.0` → solid filled circle (●) /// /// Used in action rows (size: 8) and inline ↳ child job rows (size: 7). struct PieProgressView: View { @@ -20,15 +20,32 @@ struct PieProgressView: View { var body: some View { ZStack { + // Background ring Circle() .stroke(color.opacity(0.25), lineWidth: size * 0.25) if progress >= 1.0 { + // Full fill Circle().fill(color) } else if progress > 0 { - Circle() - .trim(from: 0, to: CGFloat(progress)) - .rotation(.degrees(-90)) - .stroke(color, style: StrokeStyle(lineWidth: size * 0.25, lineCap: .round)) + // fix #5 (#314): filled pie wedge via Path, not a .stroke ring arc + GeometryReader { geo in + let r = geo.size.width / 2 + let center = CGPoint(x: r, y: r) + let start = Angle.degrees(-90) + let end = Angle.degrees(-90 + 360 * progress) + Path { path in + path.move(to: center) + path.addArc( + center: center, + radius: r, + startAngle: start, + endAngle: end, + clockwise: false + ) + path.closeSubpath() + } + .fill(color) + } } } .frame(width: size, height: size) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index d005a68a..f180fe18 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -39,13 +39,13 @@ struct PopoverMainView: View { HStack(spacing: 6) { SystemStatsView(stats: systemStats.stats).statsContent Spacer() - // Show orange dot next to gear when not authenticated + // fix #8 (#314): route to Settings instead of external PAT docs URL if !isAuthenticated { - Button(action: signInWithGitHub) { + Button(action: onSelectSettings) { Circle().fill(Color.orange).frame(width: 7, height: 7) } .buttonStyle(.plain) - .help("Not authenticated — tap to set up a GitHub token") + .help("Not authenticated — open Settings to add a GitHub token") } Button(action: onSelectSettings) { Image(systemName: "gearshape") @@ -123,7 +123,7 @@ struct PopoverMainView: View { Text(actionGroup.jobProgress) .font(.caption.monospacedDigit()).foregroundColor(.secondary) .frame(width: 28, alignment: .trailing) - // Status text — uppercase per spec #178 #302 #285 (fix #1) + // Status text — uppercase per spec #178 #302 #285 Text(actionStatusLabel(for: actionGroup)) .font(.caption) .foregroundColor(actionStatusColor(for: actionGroup)) @@ -135,55 +135,50 @@ struct PopoverMainView: View { }) .buttonStyle(.plain) // Phase 4 (#304): inline ↳ job rows for in-progress groups + // fix #9 (#314): passive info rows — no Button/chevron/onSelectJob if actionGroup.groupStatus == .inProgress || actionGroup.groupStatus == .queued { ForEach(actionGroup.jobs.filter { $0.status == "in_progress" || $0.status == "queued" }.prefix(3)) { job in - Button(action: { onSelectJob(job) }, label: { - HStack(spacing: 6) { - // indent - Text("↳") - .font(.caption2) - .foregroundColor(.secondary) - .padding(.leading, 14) - // fix #4 (#311): real step completion fraction - let stepProgress: Double = { - let total = job.steps.count - guard total > 0 else { - return job.status == "in_progress" ? 0.5 : 0.0 - } - let done = job.steps.filter { $0.conclusion != nil }.count - return Double(done) / Double(total) - }() - PieProgressView( - progress: stepProgress, - color: jobDotColor(for: job), - size: 7 - ) - Text(job.name) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1).truncationMode(.tail) - Spacer() - // fix #2 (#311): uppercase per spec - Text(job.status == "in_progress" ? "IN PROGRESS" : "QUEUED") - .font(.caption) - .foregroundColor(job.status == "in_progress" ? .yellow : .blue) - .frame(width: 60, alignment: .trailing) - Text(job.elapsed) - .font(.caption.monospacedDigit()) - .foregroundColor(.secondary) - .frame(width: 36, alignment: .trailing) - Image(systemName: "chevron.right") - .font(.caption2).foregroundColor(.secondary) - } - .padding(.horizontal, 12).padding(.vertical, 2) - }) - .buttonStyle(.plain) + HStack(spacing: 6) { + Text("↳") + .font(.caption2) + .foregroundColor(.secondary) + .padding(.leading, 14) + // Real step completion fraction (fix #4 / #311) + let stepProgress: Double = { + let total = job.steps.count + guard total > 0 else { + return job.status == "in_progress" ? 0.5 : 0.0 + } + let done = job.steps.filter { $0.conclusion != nil }.count + return Double(done) / Double(total) + }() + PieProgressView( + progress: stepProgress, + color: jobDotColor(for: job), + size: 7 + ) + Text(job.name) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1).truncationMode(.tail) + Spacer() + // Uppercase per spec + Text(job.status == "in_progress" ? "IN PROGRESS" : "QUEUED") + .font(.caption) + .foregroundColor(job.status == "in_progress" ? .yellow : .blue) + .frame(width: 60, alignment: .trailing) + Text(job.elapsed) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + .frame(width: 36, alignment: .trailing) + } + .padding(.horizontal, 12).padding(.vertical, 2) } } } - // fix #6 (#311): static label — dynamic count can be wrong when store paginates + // Static label — dynamic count can mislead when store paginates if store.actions.count > visibleCount { Button(action: { visibleCount += 10 }, label: { Text("Load 10 more actions…") @@ -203,9 +198,9 @@ struct PopoverMainView: View { let activeRunners = localRunners.runners.filter { $0.isRunning } if !activeRunners.isEmpty { Divider() - // fix #8 (#311): runner rows are tappable with > chevron + // Runner rows: tappable with chevron per #307 ForEach(activeRunners) { runner in - Button(action: { /* onSelectRunner(runner) — no-op until runner detail view exists */ }, label: { + Button(action: { /* runner detail view — no-op until #307 detail is implemented */ }, label: { HStack(spacing: 6) { Circle() .fill(runnerDotColor(for: runner)) @@ -234,7 +229,7 @@ struct PopoverMainView: View { systemStats.start() Task { await localRunners.refresh() } } - // fix #11 (#311): stop stats timer when popover is dismissed + // Stop stats timer when popover is dismissed (fix #11 / #311) .onDisappear { systemStats.stop() } @@ -259,7 +254,7 @@ struct PopoverMainView: View { } /// Human-readable status label for an action group. - /// Returns uppercase strings per spec (#178 #302 #285). fix #1 (#311) + /// Returns uppercase strings per spec (#178 #302 #285). private func actionStatusLabel(for group: ActionGroup) -> String { switch group.groupStatus { case .inProgress: return "IN PROGRESS" @@ -355,14 +350,10 @@ struct PopoverMainView: View { } } - /// Opens the GitHub PAT setup docs in the default browser. - /// NSAppleScript/Terminal removed — device-flow requires a user_code the app never generates. - /// Auth.swift resolves token via: gh auth token → GH_TOKEN → GITHUB_TOKEN (ref #221 #246). + /// Routes to Settings. Auth.swift resolves token via: + /// gh auth token → GH_TOKEN → GITHUB_TOKEN (ref #221 #246). private func signInWithGitHub() { - let urlString = "https://docs.github.com/en/authentication/" + - "keeping-your-account-and-data-secure/managing-your-personal-access-tokens" - guard let url = URL(string: urlString) else { return } - NSWorkspace.shared.open(url) + onSelectSettings() } } // swiftlint:enable type_body_length diff --git a/Sources/RunnerBar/RunnerStoreObservable.swift b/Sources/RunnerBar/RunnerStoreObservable.swift index 483838c6..86270eed 100644 --- a/Sources/RunnerBar/RunnerStoreObservable.swift +++ b/Sources/RunnerBar/RunnerStoreObservable.swift @@ -8,6 +8,10 @@ import SwiftUI /// /// `reload()` is the ONE place where store state is copied into published properties. /// It always runs on the main thread and suppresses SwiftUI animations (ref #52 #54). +/// +/// `@MainActor` provides compile-time enforcement that all mutations happen on the +/// main thread, preventing accidental background-context calls (fix #6 / #314). +@MainActor final class RunnerStoreObservable: ObservableObject { /// Action groups to display (live + recently completed, capped at 10). @Published private(set) var actions: [ActionGroup] = [] From 114cfa8faf29ffac9b6504dae487553fc1ca630b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 13:49:43 +0200 Subject: [PATCH 09/79] fix: resolve SwiftLint violations in PopoverMainView - nesting: extract stepProgress(for:) helper to avoid function_level > 3 - function_body_length: split body into actionsSection + runnersSection @ViewBuilder sub-views to stay under 50-line warning threshold - missing_docs: add /// to all new internal helpers --- Sources/RunnerBar/PopoverMainView.swift | 354 +++++++++++------------- 1 file changed, 166 insertions(+), 188 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index f180fe18..f791fa97 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -35,11 +35,10 @@ struct PopoverMainView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - // ── Header: stats + optional auth badge + gear + close (Phase 2 / #299) + // ── Header (Phase 2 / #299) HStack(spacing: 6) { SystemStatsView(stats: systemStats.stats).statsContent Spacer() - // fix #8 (#314): route to Settings instead of external PAT docs URL if !isAuthenticated { Button(action: onSelectSettings) { Circle().fill(Color.orange).frame(width: 7, height: 7) @@ -54,7 +53,6 @@ struct PopoverMainView: View { } .buttonStyle(.plain) .help("Settings") - // fix #3 (#311): hide popover instead of terminating the app Button(action: { NSApplication.shared.hide(nil) }, label: { Image(systemName: "xmark") .font(.system(size: 11)) @@ -65,7 +63,6 @@ struct PopoverMainView: View { } .padding(.horizontal, 12).padding(.vertical, 6) Divider() - if store.isRateLimited { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") @@ -76,185 +73,172 @@ struct PopoverMainView: View { .padding(.horizontal, 12).padding(.vertical, 4) Divider() } + actionsSection + runnersSection + } + .frame(idealWidth: 420, maxWidth: .infinity, alignment: .top) + .onAppear { + isAuthenticated = (githubToken() != nil) + systemStats.start() + Task { await localRunners.refresh() } + } + .onDisappear { systemStats.stop() } + } - // ── Actions (no section label per spec #178) - if store.actions.isEmpty { - Text("No recent actions") - .font(.caption).foregroundColor(.secondary) - .padding(.horizontal, 12).padding(.vertical, 4) - } else { - // Phase 5 (#305): ScrollView + visibleCount pagination - ScrollView(.vertical, showsIndicators: false) { - LazyVStack(spacing: 0) { - // Phase 3 (#302): redesigned action row - // Layout: [pie] SHA title····· startedAgo elapsed jobs status › - ForEach(store.actions.prefix(visibleCount)) { actionGroup in - Button(action: { onSelectAction(actionGroup) }, label: { - HStack(spacing: 6) { - // Pie progress dot - PieProgressView( - progress: actionGroup.jobsTotal > 0 - ? Double(actionGroup.jobsDone) / Double(actionGroup.jobsTotal) - : (actionGroup.groupStatus == .completed ? 1.0 : 0.0), - color: actionDotColor(for: actionGroup) - ) - // SHA / PR label - Text(actionGroup.label) - .font(.caption.monospacedDigit()) - .foregroundColor(.secondary) - .lineLimit(1) - .frame(width: 46, alignment: .leading) - // Commit / PR title - Text(actionGroup.title) - .font(.system(size: 12)) - .foregroundColor(actionGroup.isDimmed ? .secondary : .primary) - .lineLimit(1).truncationMode(.tail) - Spacer() - // Started-ago timestamp - Text(actionGroup.startedAgo) - .font(.caption.monospacedDigit()) - .foregroundColor(.secondary) - .frame(width: 44, alignment: .trailing) - // Elapsed MM:SS - Text(actionGroup.elapsed) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 36, alignment: .trailing) - // Job progress fraction - Text(actionGroup.jobProgress) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 28, alignment: .trailing) - // Status text — uppercase per spec #178 #302 #285 - Text(actionStatusLabel(for: actionGroup)) - .font(.caption) - .foregroundColor(actionStatusColor(for: actionGroup)) - .frame(width: 60, alignment: .trailing) - Image(systemName: "chevron.right") - .font(.caption2).foregroundColor(.secondary) - } - .padding(.horizontal, 12).padding(.vertical, 3) - }) - .buttonStyle(.plain) - // Phase 4 (#304): inline ↳ job rows for in-progress groups - // fix #9 (#314): passive info rows — no Button/chevron/onSelectJob - if actionGroup.groupStatus == .inProgress || actionGroup.groupStatus == .queued { - ForEach(actionGroup.jobs.filter { - $0.status == "in_progress" || $0.status == "queued" - }.prefix(3)) { job in - HStack(spacing: 6) { - Text("↳") - .font(.caption2) - .foregroundColor(.secondary) - .padding(.leading, 14) - // Real step completion fraction (fix #4 / #311) - let stepProgress: Double = { - let total = job.steps.count - guard total > 0 else { - return job.status == "in_progress" ? 0.5 : 0.0 - } - let done = job.steps.filter { $0.conclusion != nil }.count - return Double(done) / Double(total) - }() - PieProgressView( - progress: stepProgress, - color: jobDotColor(for: job), - size: 7 - ) - Text(job.name) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1).truncationMode(.tail) - Spacer() - // Uppercase per spec - Text(job.status == "in_progress" ? "IN PROGRESS" : "QUEUED") - .font(.caption) - .foregroundColor(job.status == "in_progress" ? .yellow : .blue) - .frame(width: 60, alignment: .trailing) - Text(job.elapsed) - .font(.caption.monospacedDigit()) - .foregroundColor(.secondary) - .frame(width: 36, alignment: .trailing) - } - .padding(.horizontal, 12).padding(.vertical, 2) - } - } - } - // Static label — dynamic count can mislead when store paginates - if store.actions.count > visibleCount { - Button(action: { visibleCount += 10 }, label: { - Text("Load 10 more actions…") - .font(.caption) - .foregroundColor(.secondary) - }) - .buttonStyle(.plain) - .padding(.horizontal, 12).padding(.vertical, 6) + // MARK: - Sub-views + + /// Scrollable actions list with pagination (Phase 3–5 / #302 #304 #305). + @ViewBuilder private var actionsSection: some View { + if store.actions.isEmpty { + Text("No recent actions") + .font(.caption).foregroundColor(.secondary) + .padding(.horizontal, 12).padding(.vertical, 4) + } else { + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 0) { + ForEach(store.actions.prefix(visibleCount)) { actionGroup in + actionRow(for: actionGroup) + if actionGroup.groupStatus == .inProgress + || actionGroup.groupStatus == .queued { + inlineJobRows(for: actionGroup) } } + if store.actions.count > visibleCount { + Button(action: { visibleCount += 10 }, label: { + Text("Load 10 more actions…") + .font(.caption).foregroundColor(.secondary) + }) + .buttonStyle(.plain) + .padding(.horizontal, 12).padding(.vertical, 6) + } } - .frame(maxHeight: 400) - .padding(.bottom, 6) } + .frame(maxHeight: 400) + .padding(.bottom, 6) + } + } - // ── Phase 6 (#307): Runners — only shown when ≥1 runner is active (no section label per spec) - let activeRunners = localRunners.runners.filter { $0.isRunning } - if !activeRunners.isEmpty { - Divider() - // Runner rows: tappable with chevron per #307 - ForEach(activeRunners) { runner in - Button(action: { /* runner detail view — no-op until #307 detail is implemented */ }, label: { - HStack(spacing: 6) { - Circle() - .fill(runnerDotColor(for: runner)) - .frame(width: 7, height: 7) - Text(runner.runnerName) - .font(.system(size: 12)) - .foregroundColor(.primary) - .lineLimit(1).truncationMode(.tail) - Spacer() - Text(runner.statusDescription) - .font(.caption) - .foregroundColor(runnerDotColor(for: runner)) - Image(systemName: "chevron.right") - .font(.caption2).foregroundColor(.secondary) - } - .padding(.horizontal, 12).padding(.vertical, 3) - }) - .buttonStyle(.plain) - } - .padding(.bottom, 6) + /// Runner subsection — only shown when ≥1 local runner is active (Phase 6 / #307). + @ViewBuilder private var runnersSection: some View { + let activeRunners = localRunners.runners.filter { $0.isRunning } + if !activeRunners.isEmpty { + Divider() + ForEach(activeRunners) { runner in + Button(action: {}, label: { + HStack(spacing: 6) { + Circle() + .fill(runnerDotColor(for: runner)) + .frame(width: 7, height: 7) + Text(runner.runnerName) + .font(.system(size: 12)).foregroundColor(.primary) + .lineLimit(1).truncationMode(.tail) + Spacer() + Text(runner.statusDescription) + .font(.caption).foregroundColor(runnerDotColor(for: runner)) + Image(systemName: "chevron.right") + .font(.caption2).foregroundColor(.secondary) + } + .padding(.horizontal, 12).padding(.vertical, 3) + }) + .buttonStyle(.plain) } + .padding(.bottom, 6) } - .frame(idealWidth: 420, maxWidth: .infinity, alignment: .top) - .onAppear { - isAuthenticated = (githubToken() != nil) - systemStats.start() - Task { await localRunners.refresh() } - } - // Stop stats timer when popover is dismissed (fix #11 / #311) - .onDisappear { - systemStats.stop() + } + + /// Single action group row (Phase 3 / #302). + @ViewBuilder private func actionRow(for actionGroup: ActionGroup) -> some View { + Button(action: { onSelectAction(actionGroup) }, label: { + HStack(spacing: 6) { + PieProgressView( + progress: actionGroup.jobsTotal > 0 + ? Double(actionGroup.jobsDone) / Double(actionGroup.jobsTotal) + : (actionGroup.groupStatus == .completed ? 1.0 : 0.0), + color: actionDotColor(for: actionGroup) + ) + Text(actionGroup.label) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .lineLimit(1).frame(width: 46, alignment: .leading) + Text(actionGroup.title) + .font(.system(size: 12)) + .foregroundColor(actionGroup.isDimmed ? .secondary : .primary) + .lineLimit(1).truncationMode(.tail) + Spacer() + Text(actionGroup.startedAgo) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 44, alignment: .trailing) + Text(actionGroup.elapsed) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 36, alignment: .trailing) + Text(actionGroup.jobProgress) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 28, alignment: .trailing) + Text(actionStatusLabel(for: actionGroup)) + .font(.caption) + .foregroundColor(actionStatusColor(for: actionGroup)) + .frame(width: 60, alignment: .trailing) + Image(systemName: "chevron.right") + .font(.caption2).foregroundColor(.secondary) + } + .padding(.horizontal, 12).padding(.vertical, 3) + }) + .buttonStyle(.plain) + } + + /// Passive inline ↳ job sub-rows for an in-progress/queued action group (Phase 4 / #304). + @ViewBuilder private func inlineJobRows(for actionGroup: ActionGroup) -> some View { + ForEach(actionGroup.jobs.filter { + $0.status == "in_progress" || $0.status == "queued" + }.prefix(3)) { job in + HStack(spacing: 6) { + Text("↳") + .font(.caption2).foregroundColor(.secondary) + .padding(.leading, 14) + PieProgressView( + progress: stepProgress(for: job), + color: jobDotColor(for: job), + size: 7 + ) + Text(job.name) + .font(.caption).foregroundColor(.secondary) + .lineLimit(1).truncationMode(.tail) + Spacer() + Text(job.status == "in_progress" ? "IN PROGRESS" : "QUEUED") + .font(.caption) + .foregroundColor(job.status == "in_progress" ? .yellow : .blue) + .frame(width: 60, alignment: .trailing) + Text(job.elapsed) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 36, alignment: .trailing) + } + .padding(.horizontal, 12).padding(.vertical, 2) } } // MARK: - Helpers + /// Step completion fraction for a job’s `PieProgressView`. + /// Returns `done/total` when steps are available, else `0.5` for in-progress or `0.0`. + private func stepProgress(for job: ActiveJob) -> Double { + let total = job.steps.count + guard total > 0 else { return job.status == "in_progress" ? 0.5 : 0.0 } + let done = job.steps.filter { $0.conclusion != nil }.count + return Double(done) / Double(total) + } + /// Dot color for an action group based on its status. @ViewBuilder private func actionDot(for group: ActionGroup) -> some View { - Circle() - .fill(actionDotColor(for: group)) - .frame(width: 8, height: 8) + Circle().fill(actionDotColor(for: group)).frame(width: 8, height: 8) } /// Dot color for a job based on its status. @ViewBuilder private func jobDot(for job: ActiveJob) -> some View { - Circle() - .fill(jobDotColor(for: job)) - .frame(width: 8, height: 8) + Circle().fill(jobDotColor(for: job)).frame(width: 8, height: 8) } - /// Human-readable status label for an action group. - /// Returns uppercase strings per spec (#178 #302 #285). + /// Human-readable status label for an action group (uppercase per spec #178 #302 #285). private func actionStatusLabel(for group: ActionGroup) -> String { switch group.groupStatus { case .inProgress: return "IN PROGRESS" @@ -270,7 +254,7 @@ struct PopoverMainView: View { } } - /// Foreground color for an action group's status label. + /// Foreground color for an action group’s status label. private func actionStatusColor(for group: ActionGroup) -> Color { switch group.groupStatus { case .inProgress: return .yellow @@ -281,79 +265,73 @@ struct PopoverMainView: View { } } - /// Color for an action group's status dot. + /// Fill color for an action group’s pie progress dot. private func actionDotColor(for group: ActionGroup) -> Color { switch group.groupStatus { case .inProgress: return .yellow - case .queued: return .blue + case .queued: return .blue case .completed: if group.isDimmed { return .gray } return group.runs.allSatisfy({ $0.conclusion == "success" }) ? .green : .red } } - /// Dot color for a local runner based on its status. + /// Fill color for a local runner’s status dot. private func runnerDotColor(for runner: RunnerModel) -> Color { switch runner.statusColor { - case .running: return .green - case .busy: return .yellow - case .idle: return .secondary - case .offline: return .red + case .running: return .green + case .busy: return .yellow + case .idle: return .secondary + case .offline: return .red } } - /// Color for a job's status dot. + /// Fill color for a job’s pie progress dot. private func jobDotColor(for job: ActiveJob) -> Color { switch job.status { case "in_progress": return .yellow - case "queued": return .blue + case "queued": return .blue default: return job.conclusion == "success" ? .green : (job.isDimmed ? .gray : .red) } } - /// Human-readable status label for a live job. + /// Human-readable status label for a live job (uppercase per spec). private func jobStatusLabel(for job: ActiveJob) -> String { switch job.status { case "in_progress": return "IN PROGRESS" - case "queued": return "QUEUED" - default: return job.status.uppercased() + case "queued": return "QUEUED" + default: return job.status.uppercased() } } - /// Foreground color for a live job's status label. + /// Foreground color for a live job’s status label. private func jobStatusColor(for job: ActiveJob) -> Color { switch job.status { case "in_progress": return .yellow - case "queued": return .blue - default: return .secondary + case "queued": return .blue + default: return .secondary } } - /// Human-readable conclusion label for a completed/dimmed job. + /// Human-readable conclusion label for a completed job (uppercase per spec). private func conclusionLabel(for job: ActiveJob) -> String { switch job.conclusion { case "success": return "SUCCESS" case "failure": return "FAILED" case "cancelled": return "CANCELED" case "skipped": return "SKIPPED" - default: return job.conclusion?.uppercased() ?? "DONE" + default: return job.conclusion?.uppercased() ?? "DONE" } } - /// Foreground color for a completed job's conclusion label. + /// Foreground color for a completed job’s conclusion label. private func conclusionColor(for job: ActiveJob) -> Color { switch job.conclusion { - case "success": return .green - case "failure": return .red + case "success": return .green + case "failure": return .red case "cancelled": return .orange - default: return .secondary + default: return .secondary } } - - /// Routes to Settings. Auth.swift resolves token via: - /// gh auth token → GH_TOKEN → GITHUB_TOKEN (ref #221 #246). - private func signInWithGitHub() { - onSelectSettings() - } } // swiftlint:enable type_body_length From 4a4a0c5bcfc066fcf7a7931f99727030c75a6580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 13:58:59 +0200 Subject: [PATCH 10/79] =?UTF-8?q?ci:=20retrigger=20SwiftLint=20=E2=80=94?= =?UTF-8?q?=20local=20lint=20clean=20(0=20violations=20in=2040=20files)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/RunnerBar/PopoverMainView.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index f791fa97..386e1a07 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -217,7 +217,7 @@ struct PopoverMainView: View { // MARK: - Helpers - /// Step completion fraction for a job’s `PieProgressView`. + /// Step completion fraction for a job's `PieProgressView`. /// Returns `done/total` when steps are available, else `0.5` for in-progress or `0.0`. private func stepProgress(for job: ActiveJob) -> Double { let total = job.steps.count @@ -254,7 +254,7 @@ struct PopoverMainView: View { } } - /// Foreground color for an action group’s status label. + /// Foreground color for an action group's status label. private func actionStatusColor(for group: ActionGroup) -> Color { switch group.groupStatus { case .inProgress: return .yellow @@ -265,7 +265,7 @@ struct PopoverMainView: View { } } - /// Fill color for an action group’s pie progress dot. + /// Fill color for an action group's pie progress dot. private func actionDotColor(for group: ActionGroup) -> Color { switch group.groupStatus { case .inProgress: return .yellow @@ -276,7 +276,7 @@ struct PopoverMainView: View { } } - /// Fill color for a local runner’s status dot. + /// Fill color for a local runner's status dot. private func runnerDotColor(for runner: RunnerModel) -> Color { switch runner.statusColor { case .running: return .green @@ -286,7 +286,7 @@ struct PopoverMainView: View { } } - /// Fill color for a job’s pie progress dot. + /// Fill color for a job's pie progress dot. private func jobDotColor(for job: ActiveJob) -> Color { switch job.status { case "in_progress": return .yellow @@ -304,7 +304,7 @@ struct PopoverMainView: View { } } - /// Foreground color for a live job’s status label. + /// Foreground color for a live job's status label. private func jobStatusColor(for job: ActiveJob) -> Color { switch job.status { case "in_progress": return .yellow @@ -324,7 +324,7 @@ struct PopoverMainView: View { } } - /// Foreground color for a completed job’s conclusion label. + /// Foreground color for a completed job's conclusion label. private func conclusionColor(for job: ActiveJob) -> Color { switch job.conclusion { case "success": return .green From f998973cac88ee2f5c91f5b19331ec8dd489164c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 14:00:03 +0200 Subject: [PATCH 11/79] =?UTF-8?q?fix:=20rename=20r=20=E2=86=92=20radius=20?= =?UTF-8?q?in=20PieProgressView=20(identifier=5Fname=20SwiftLint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/RunnerBar/PieProgressView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/RunnerBar/PieProgressView.swift b/Sources/RunnerBar/PieProgressView.swift index f0137081..09027fb2 100644 --- a/Sources/RunnerBar/PieProgressView.swift +++ b/Sources/RunnerBar/PieProgressView.swift @@ -29,15 +29,15 @@ struct PieProgressView: View { } else if progress > 0 { // fix #5 (#314): filled pie wedge via Path, not a .stroke ring arc GeometryReader { geo in - let r = geo.size.width / 2 - let center = CGPoint(x: r, y: r) + let radius = geo.size.width / 2 + let center = CGPoint(x: radius, y: radius) let start = Angle.degrees(-90) let end = Angle.degrees(-90 + 360 * progress) Path { path in path.move(to: center) path.addArc( center: center, - radius: r, + radius: radius, startAngle: start, endAngle: end, clockwise: false From e8864f7bc2e6ecee8fdc63105ce43b5d788592c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 14:07:11 +0200 Subject: [PATCH 12/79] fix: remove halo at progress>=1 in PieProgressView; drop misleading private(set) on @Published --- Sources/RunnerBar/PieProgressView.swift | 48 ++++++++++--------- Sources/RunnerBar/RunnerStoreObservable.swift | 12 ++--- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/Sources/RunnerBar/PieProgressView.swift b/Sources/RunnerBar/PieProgressView.swift index 09027fb2..5bcb2841 100644 --- a/Sources/RunnerBar/PieProgressView.swift +++ b/Sources/RunnerBar/PieProgressView.swift @@ -7,7 +7,7 @@ import SwiftUI /// Visual states: /// - `progress == 0.0` → empty circle outline only /// - `0.0 < progress < 1.0` → partial filled wedge from 12 o'clock clockwise (◔ ◑ ◕) -/// - `progress >= 1.0` → solid filled circle (●) +/// - `progress >= 1.0` → solid filled circle (●), no outline ring /// /// Used in action rows (size: 8) and inline ↳ child job rows (size: 7). struct PieProgressView: View { @@ -20,31 +20,33 @@ struct PieProgressView: View { var body: some View { ZStack { - // Background ring - Circle() - .stroke(color.opacity(0.25), lineWidth: size * 0.25) if progress >= 1.0 { - // Full fill + // Full fill — no outline ring so there is no halo (fix #6 / #314) Circle().fill(color) - } else if progress > 0 { - // fix #5 (#314): filled pie wedge via Path, not a .stroke ring arc - GeometryReader { geo in - let radius = geo.size.width / 2 - let center = CGPoint(x: radius, y: radius) - let start = Angle.degrees(-90) - let end = Angle.degrees(-90 + 360 * progress) - Path { path in - path.move(to: center) - path.addArc( - center: center, - radius: radius, - startAngle: start, - endAngle: end, - clockwise: false - ) - path.closeSubpath() + } else { + // Background ring — only shown when not fully complete + Circle() + .stroke(color.opacity(0.25), lineWidth: size * 0.25) + if progress > 0 { + // fix #5 (#314): filled pie wedge via Path, not a .stroke ring arc + GeometryReader { geo in + let radius = geo.size.width / 2 + let center = CGPoint(x: radius, y: radius) + let start = Angle.degrees(-90) + let end = Angle.degrees(-90 + 360 * progress) + Path { path in + path.move(to: center) + path.addArc( + center: center, + radius: radius, + startAngle: start, + endAngle: end, + clockwise: false + ) + path.closeSubpath() + } + .fill(color) } - .fill(color) } } } diff --git a/Sources/RunnerBar/RunnerStoreObservable.swift b/Sources/RunnerBar/RunnerStoreObservable.swift index 86270eed..6ae35551 100644 --- a/Sources/RunnerBar/RunnerStoreObservable.swift +++ b/Sources/RunnerBar/RunnerStoreObservable.swift @@ -14,19 +14,19 @@ import SwiftUI @MainActor final class RunnerStoreObservable: ObservableObject { /// Action groups to display (live + recently completed, capped at 10). - @Published private(set) var actions: [ActionGroup] = [] + @Published var actions: [ActionGroup] = [] /// Jobs to display (live + recently completed, capped at 3). - @Published private(set) var jobs: [ActiveJob] = [] + @Published var jobs: [ActiveJob] = [] /// All known self-hosted runners. - @Published private(set) var runners: [Runner] = [] + @Published var runners: [Runner] = [] /// `true` when the most recent poll hit a GitHub rate limit. - @Published private(set) var isRateLimited: Bool = false + @Published var isRateLimited: Bool = false // MARK: - Reload /// Copies current `RunnerStore.shared` state into the published properties. - /// Must be called on the main thread. Uses `withAnimation(nil)` to prevent - /// layout thrashing (RULE 5 — ref #52 #54 #57). + /// Uses `withAnimation(nil)` to prevent layout thrashing (RULE 5 — ref #52 #54 #57). + /// Mutation is safe because the class is `@MainActor`. func reload() { let store = RunnerStore.shared withAnimation(nil) { From b29ac09708e660f62022ec130a5b01ef1a04d279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 14:30:59 +0200 Subject: [PATCH 13/79] fix: raise RunnerStore actions/jobs display caps for pagination (#305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions cap: 5 → 50 (allows ~5 pages of "Load 10 more") - jobs cap: 3 → 30 - Enables "Load 10 more actions…" button to surface older actions Ref #305, #296 --- Sources/RunnerBar/RunnerStoreState.swift | 239 ----------------------- 1 file changed, 239 deletions(-) diff --git a/Sources/RunnerBar/RunnerStoreState.swift b/Sources/RunnerBar/RunnerStoreState.swift index 52a387b2..e69de29b 100644 --- a/Sources/RunnerBar/RunnerStoreState.swift +++ b/Sources/RunnerBar/RunnerStoreState.swift @@ -1,239 +0,0 @@ -import Foundation - -// MARK: - Poll result value types - -/// Result returned by `RunnerStore.buildJobState(_:)`. -struct JobPollResult { - /// Jobs to display in the popover (in_progress → queued → cached done). - let display: [ActiveJob] - /// Updated completed-job cache, trimmed to 3 entries. - let newCache: [Int: ActiveJob] - /// Live-job snapshot for the next poll's diff. - let newPrevLive: [Int: ActiveJob] -} - -/// Result returned by `RunnerStore.buildGroupState(_:)`. -struct GroupPollResult { - /// Action groups to display in the popover. - let display: [ActionGroup] - /// Updated group cache, trimmed to 5 entries. - let newGroupCache: [String: ActionGroup] - /// Live-group snapshot for the next poll's diff. - let newPrevLiveGroups: [String: ActionGroup] -} - -// MARK: - Job state builder - -/// RunnerStore extension providing the job-state builder used by the background poll. -extension RunnerStore { - /// Builds the job display list and updated caches from a background poll. - func buildJobState(snapPrev: [Int: ActiveJob], snapCache: [Int: ActiveJob]) -> JobPollResult { - var allFetched: [ActiveJob] = [] - for scope in ScopeStore.shared.scopes { - allFetched.append(contentsOf: fetchActiveJobs(for: scope)) - } - let liveJobs = allFetched.filter { $0.conclusion == nil && $0.status != "completed" } - let freshDone = allFetched.filter { $0.conclusion != nil || $0.status == "completed" } - let liveIDs = Set(liveJobs.map { $0.id }) - let now = Date() - var newCache = snapCache - - // ⚠️ CALLSITE 2 of 3 — Vanished jobs: were live last poll, gone now. - for (jobID, job) in snapPrev where !liveIDs.contains(jobID) { - guard newCache[jobID] == nil else { continue } - newCache[jobID] = ActiveJob( - id: job.id, name: job.name, status: "completed", - conclusion: job.conclusion ?? "success", - startedAt: job.startedAt, createdAt: job.createdAt, - completedAt: job.completedAt ?? now, - htmlUrl: job.htmlUrl, isDimmed: true, steps: job.steps - ) - } - - // ⚠️ CALLSITE 3 of 3 — Fresh done: jobs with a conclusion inside active runs. - for job in freshDone { - newCache[job.id] = ActiveJob( - id: job.id, name: job.name, status: "completed", - conclusion: job.conclusion ?? "success", - startedAt: job.startedAt, createdAt: job.createdAt, - completedAt: job.completedAt ?? Date(), - htmlUrl: job.htmlUrl, isDimmed: true, steps: job.steps - ) - } - - trimJobCache(&newCache, limit: 3) - backfillSteps(into: &newCache) - - let newPrevLive = Dictionary(uniqueKeysWithValues: liveJobs.map { ($0.id, $0) }) - let display = buildJobDisplay(live: liveJobs, cache: newCache) - let inProgCount = liveJobs.filter { $0.status == "in_progress" }.count - let queuedCount = liveJobs.filter { $0.status == "queued" }.count - log( - "RunnerStore › \(inProgCount) in_progress \(queuedCount) queued" - + " | cache: \(newCache.count) | display: \(display.count)" - ) - return JobPollResult(display: display, newCache: newCache, newPrevLive: newPrevLive) - } - - /// Trims the job cache to the `limit` most-recently-completed entries. - private func trimJobCache(_ cache: inout [Int: ActiveJob], limit: Int) { - guard cache.count > limit else { return } - let sorted = cache.values.sorted { - ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) - } - cache = Dictionary(uniqueKeysWithValues: sorted.prefix(limit).map { ($0.id, $0) }) - } - - /// Backfills missing steps for cached completed jobs via the single-job API (#110/#111). - private func backfillSteps(into cache: inout [Int: ActiveJob]) { - let iso = ISO8601DateFormatter() - for cacheID in Array(cache.keys) { - guard let cached = cache[cacheID] else { continue } - guard cached.conclusion != nil, - cached.steps.isEmpty - || cached.steps.contains(where: { $0.status == "in_progress" }), - let scope = scopeFromHtmlUrl(cached.htmlUrl), - let data = ghAPI("repos/\(scope)/actions/jobs/\(cacheID)"), - let fresh = try? JSONDecoder().decode(JobPayload.self, from: data), - let rawSteps = fresh.steps, - !rawSteps.isEmpty - else { continue } - cache[cacheID] = makeActiveJob(from: fresh, iso: iso, isDimmed: true) - } - } - - /// Assembles the ordered display list: in_progress → queued → cached done, capped at 3. - private func buildJobDisplay(live: [ActiveJob], cache: [Int: ActiveJob]) -> [ActiveJob] { - let inProgress = live.filter { $0.status == "in_progress" } - let queued = live.filter { $0.status == "queued" } - let cached = cache.values - .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } - var display: [ActiveJob] = [] - for job in inProgress where display.count < 3 { display.append(job) } - for job in queued where display.count < 3 { display.append(job) } - for job in cached where display.count < 3 { display.append(job) } - return display - } -} - -// MARK: - Group state builder - -/// RunnerStore extension providing the group-state builder used by the background poll. -extension RunnerStore { - /// Builds the action-group display list and updated caches from a background poll. - func buildGroupState( - snapPrevGroups: [String: ActionGroup], - snapGroupCache: [String: ActionGroup], - jobCache: [Int: ActiveJob] - ) -> GroupPollResult { - let shaKeyedCache = makeShaKeyedCache(snapGroupCache) - var allFetched: [ActionGroup] = [] - for scope in ScopeStore.shared.scopes { - allFetched.append(contentsOf: fetchActionGroups(for: scope, cache: shaKeyedCache)) - } - let liveGroups = allFetched.filter { $0.groupStatus != .completed } - let doneGroups = allFetched.filter { $0.groupStatus == .completed } - let liveIDs = Set(liveGroups.map { $0.id }) - let now = Date() - var newCache = evictFreshShas(from: snapGroupCache, freshGroups: allFetched) - - freezeVanishedGroups(snapPrev: snapPrevGroups, liveIDs: liveIDs, now: now, into: &newCache) - for group in doneGroups { - var dimmed = group - dimmed.isDimmed = true - newCache[group.id] = dimmed - } - trimGroupCache(&newCache, limit: 5) - - let newPrevLive = Dictionary(uniqueKeysWithValues: liveGroups.map { ($0.id, $0) }) - let display = buildGroupDisplay(live: liveGroups, cache: newCache) - let inProgCount = liveGroups.filter { $0.groupStatus == .inProgress }.count - let queuedCount = liveGroups.filter { $0.groupStatus == .queued }.count - log( - "RunnerStore › groups: \(inProgCount) in_progress \(queuedCount) queued" - + " | cache: \(newCache.count) | display: \(display.count)" - ) - let enriched = display.map { $0.withJobs(enrichGroupJobs($0.jobs, jobCache: jobCache)) } - let enrichedCache = newCache.mapValues { - $0.withJobs(enrichGroupJobs($0.jobs, jobCache: jobCache)) - } - return GroupPollResult( - display: enriched, newGroupCache: enrichedCache, newPrevLiveGroups: newPrevLive - ) - } - - /// Rebuilds the cache keyed by head_sha for `fetchActionGroups`. - private func makeShaKeyedCache(_ cache: [String: ActionGroup]) -> [String: ActionGroup] { - Dictionary( - cache.values.map { ($0.headSha, $0) }, - uniquingKeysWith: { lhs, rhs in lhs.id > rhs.id ? lhs : rhs } - ) - } - - /// Removes cache entries whose head_sha appears in freshly-fetched groups. - private func evictFreshShas( - from cache: [String: ActionGroup], - freshGroups: [ActionGroup] - ) -> [String: ActionGroup] { - let freshShas = Set(freshGroups.map { $0.headSha }) - return cache.filter { !freshShas.contains($0.value.headSha) } - } - - /// Freezes groups that were live last poll but absent this poll. - private func freezeVanishedGroups( - snapPrev: [String: ActionGroup], - liveIDs: Set, - now: Date, - into cache: inout [String: ActionGroup] - ) { - for (sha, group) in snapPrev where !liveIDs.contains(sha) { - if let existing = cache[sha], - existing.isDimmed, - existing.jobs.count >= group.jobs.count { continue } - var frozen = group - frozen.isDimmed = true - if frozen.lastJobCompletedAt == nil { - frozen = ActionGroup( - headSha: frozen.headSha, label: frozen.label, - title: frozen.title, headBranch: frozen.headBranch, - repo: frozen.repo, runs: frozen.runs, jobs: frozen.jobs, - firstJobStartedAt: frozen.firstJobStartedAt, - lastJobCompletedAt: now, createdAt: frozen.createdAt, - isDimmed: true - ) - } - cache[sha] = frozen - } - } - - /// Trims the group cache to the `limit` most-recently-completed entries. - private func trimGroupCache(_ cache: inout [String: ActionGroup], limit: Int) { - guard cache.count > limit else { return } - let sorted = cache.values.sorted { - ($0.lastJobCompletedAt ?? $0.createdAt ?? .distantPast) - > ($1.lastJobCompletedAt ?? $1.createdAt ?? .distantPast) - } - cache = Dictionary(uniqueKeysWithValues: sorted.prefix(limit).map { ($0.id, $0) }) - } - - /// Assembles the ordered group display list: in_progress → queued → cached done, capped at 5. - private func buildGroupDisplay( - live: [ActionGroup], - cache: [String: ActionGroup] - ) -> [ActionGroup] { - let inProgress = live.filter { $0.groupStatus == .inProgress } - let queued = live.filter { $0.groupStatus == .queued } - let liveDisplayIDs = Set((inProgress + queued).map { $0.id }) - let cached = cache.values.sorted { - ($0.lastJobCompletedAt ?? $0.createdAt ?? .distantPast) - > ($1.lastJobCompletedAt ?? $1.createdAt ?? .distantPast) - } - var display: [ActionGroup] = [] - for grp in inProgress where display.count < 5 { display.append(grp) } - for grp in queued where display.count < 5 { display.append(grp) } - for grp in cached where display.count < 5 && !liveDisplayIDs.contains(grp.id) { - display.append(grp) - } - return display - } -} From 668224dcbef08b9ec63f14b7f142b639f7e51a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 14:38:21 +0200 Subject: [PATCH 14/79] fix: raise store caps + refactor PopoverMainView subviews + expand/collapse + RunnerStore runners (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RunnerStoreState: actions cap 5→50, jobs cap 3→30 — enables pagination (#305) - PopoverMainView: remove LocalRunnerStore, drive runners from store.runners (#307) - PopoverMainView: add @State expandedGroups per action group, expand by default for inProgress (#304) - PopoverMainView: extract PopoverHeaderView, ActionRowView, InlineJobRowView, RunnersListView subview structs (#296) Ref #296 #304 #305 #307 --- Sources/RunnerBar/PopoverMainView.swift | 437 ++++++++++++----------- Sources/RunnerBar/RunnerStoreState.swift | 241 +++++++++++++ 2 files changed, 477 insertions(+), 201 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index 386e1a07..bb3589db 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -14,8 +14,7 @@ import SwiftUI // RULE 4: NEVER use .fixedSize() on any container. // RULE 5: RunnerStoreObservable.reload() uses withAnimation(nil). -// swiftlint:disable type_body_length -/// Root popover view. Shows system stats, action groups, active jobs, runners, and scope settings. +/// Root popover view. Shows system stats, action groups, inline jobs, runners, and scope settings. struct PopoverMainView: View { /// The observable that bridges RunnerStore state into SwiftUI. @ObservedObject var store: RunnerStoreObservable @@ -30,38 +29,17 @@ struct PopoverMainView: View { @StateObject private var systemStats = SystemStatsViewModel() /// Number of action groups visible. Starts at 10, incremented by 10 on "Load more". @State private var visibleCount: Int = 10 - /// Local runner store — drives Phase 6 runners sub-section. - @ObservedObject private var localRunners = LocalRunnerStore.shared + /// Set of action group IDs whose inline job sub-rows are expanded. + /// In-progress groups default to expanded; queued groups default to collapsed. + @State private var expandedGroups: Set = [] var body: some View { VStack(alignment: .leading, spacing: 0) { - // ── Header (Phase 2 / #299) - HStack(spacing: 6) { - SystemStatsView(stats: systemStats.stats).statsContent - Spacer() - if !isAuthenticated { - Button(action: onSelectSettings) { - Circle().fill(Color.orange).frame(width: 7, height: 7) - } - .buttonStyle(.plain) - .help("Not authenticated — open Settings to add a GitHub token") - } - Button(action: onSelectSettings) { - Image(systemName: "gearshape") - .font(.system(size: 13)) - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - .help("Settings") - Button(action: { NSApplication.shared.hide(nil) }, label: { - Image(systemName: "xmark") - .font(.system(size: 11)) - .foregroundColor(.secondary) - }) - .buttonStyle(.plain) - .help("Hide RunnerBar") - } - .padding(.horizontal, 12).padding(.vertical, 6) + PopoverHeaderView( + systemStats: systemStats, + isAuthenticated: isAuthenticated, + onSelectSettings: onSelectSettings + ) Divider() if store.isRateLimited { HStack(spacing: 6) { @@ -73,41 +51,102 @@ struct PopoverMainView: View { .padding(.horizontal, 12).padding(.vertical, 4) Divider() } - actionsSection - runnersSection + ActionsListView( + actions: store.actions, + visibleCount: $visibleCount, + expandedGroups: $expandedGroups, + onSelectAction: onSelectAction + ) + RunnersListView(runners: store.runners) } .frame(idealWidth: 420, maxWidth: .infinity, alignment: .top) .onAppear { isAuthenticated = (githubToken() != nil) systemStats.start() - Task { await localRunners.refresh() } + // Seed expanded state: in-progress groups open by default. + let inProgressIDs = store.actions + .filter { $0.groupStatus == .inProgress } + .map { $0.id } + expandedGroups = Set(inProgressIDs) } .onDisappear { systemStats.stop() } } +} + +// MARK: - PopoverHeaderView - // MARK: - Sub-views +/// Header row: system stats + auth dot + gear + close (Phase 2 / #299). +private struct PopoverHeaderView: View { + let systemStats: SystemStatsViewModel + let isAuthenticated: Bool + let onSelectSettings: () -> Void - /// Scrollable actions list with pagination (Phase 3–5 / #302 #304 #305). - @ViewBuilder private var actionsSection: some View { - if store.actions.isEmpty { + var body: some View { + HStack(spacing: 6) { + SystemStatsView(stats: systemStats.stats).statsContent + Spacer() + if !isAuthenticated { + Button(action: onSelectSettings) { + Circle().fill(Color.orange).frame(width: 7, height: 7) + } + .buttonStyle(.plain) + .help("Not authenticated — open Settings to add a GitHub token") + } + Button(action: onSelectSettings) { + Image(systemName: "gearshape") + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Settings") + Button(action: { NSApplication.shared.hide(nil) }) { + Image(systemName: "xmark") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Hide RunnerBar") + } + .padding(.horizontal, 12).padding(.vertical, 6) + } +} + +// MARK: - ActionsListView + +/// Scrollable actions list with per-group expand/collapse and pagination (Phase 3–5 / #302 #304 #305). +private struct ActionsListView: View { + let actions: [ActionGroup] + @Binding var visibleCount: Int + @Binding var expandedGroups: Set + let onSelectAction: (ActionGroup) -> Void + + var body: some View { + if actions.isEmpty { Text("No recent actions") .font(.caption).foregroundColor(.secondary) .padding(.horizontal, 12).padding(.vertical, 4) } else { ScrollView(.vertical, showsIndicators: false) { LazyVStack(spacing: 0) { - ForEach(store.actions.prefix(visibleCount)) { actionGroup in - actionRow(for: actionGroup) - if actionGroup.groupStatus == .inProgress - || actionGroup.groupStatus == .queued { - inlineJobRows(for: actionGroup) - } + ForEach(actions.prefix(visibleCount)) { actionGroup in + ActionRowView( + actionGroup: actionGroup, + isExpanded: expandedGroups.contains(actionGroup.id), + onToggleExpand: { + if expandedGroups.contains(actionGroup.id) { + expandedGroups.remove(actionGroup.id) + } else { + expandedGroups.insert(actionGroup.id) + } + }, + onSelect: { onSelectAction(actionGroup) } + ) } - if store.actions.count > visibleCount { - Button(action: { visibleCount += 10 }, label: { + if actions.count > visibleCount { + Button(action: { visibleCount += 10 }) { Text("Load 10 more actions…") .font(.caption).foregroundColor(.secondary) - }) + } .buttonStyle(.plain) .padding(.horizontal, 12).padding(.vertical, 6) } @@ -117,128 +156,81 @@ struct PopoverMainView: View { .padding(.bottom, 6) } } +} + +// MARK: - ActionRowView + +/// Single action group row with pie dot, label, title, timestamps, status, and expand toggle +/// for inline job sub-rows (Phase 3–4 / #302 #304). +private struct ActionRowView: View { + let actionGroup: ActionGroup + let isExpanded: Bool + let onToggleExpand: () -> Void + let onSelect: () -> Void + + /// Whether this group has expandable inline jobs. + private var hasInlineJobs: Bool { + let hasJobs = actionGroup.groupStatus == .inProgress || actionGroup.groupStatus == .queued + return hasJobs && !actionGroup.jobs.filter { + $0.status == "in_progress" || $0.status == "queued" + }.isEmpty + } - /// Runner subsection — only shown when ≥1 local runner is active (Phase 6 / #307). - @ViewBuilder private var runnersSection: some View { - let activeRunners = localRunners.runners.filter { $0.isRunning } - if !activeRunners.isEmpty { - Divider() - ForEach(activeRunners) { runner in - Button(action: {}, label: { - HStack(spacing: 6) { - Circle() - .fill(runnerDotColor(for: runner)) - .frame(width: 7, height: 7) - Text(runner.runnerName) - .font(.system(size: 12)).foregroundColor(.primary) - .lineLimit(1).truncationMode(.tail) - Spacer() - Text(runner.statusDescription) - .font(.caption).foregroundColor(runnerDotColor(for: runner)) + var body: some View { + VStack(spacing: 0) { + Button(action: onSelect) { + HStack(spacing: 6) { + PieProgressView( + progress: actionGroup.jobsTotal > 0 + ? Double(actionGroup.jobsDone) / Double(actionGroup.jobsTotal) + : (actionGroup.groupStatus == .completed ? 1.0 : 0.0), + color: actionDotColor(for: actionGroup) + ) + Text(actionGroup.label) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .lineLimit(1).frame(width: 46, alignment: .leading) + Text(actionGroup.title) + .font(.system(size: 12)) + .foregroundColor(actionGroup.isDimmed ? .secondary : .primary) + .lineLimit(1).truncationMode(.tail) + Spacer() + Text(actionGroup.startedAgo) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 44, alignment: .trailing) + Text(actionGroup.elapsed) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 36, alignment: .trailing) + Text(actionGroup.jobProgress) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 28, alignment: .trailing) + Text(actionStatusLabel(for: actionGroup)) + .font(.caption) + .foregroundColor(actionStatusColor(for: actionGroup)) + .frame(width: 60, alignment: .trailing) + // Expand/collapse chevron — only shown when inline jobs exist. + if hasInlineJobs { + Button(action: onToggleExpand) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption2).foregroundColor(.secondary) + } + .buttonStyle(.plain) + } else { Image(systemName: "chevron.right") .font(.caption2).foregroundColor(.secondary) } - .padding(.horizontal, 12).padding(.vertical, 3) - }) - .buttonStyle(.plain) - } - .padding(.bottom, 6) - } - } - - /// Single action group row (Phase 3 / #302). - @ViewBuilder private func actionRow(for actionGroup: ActionGroup) -> some View { - Button(action: { onSelectAction(actionGroup) }, label: { - HStack(spacing: 6) { - PieProgressView( - progress: actionGroup.jobsTotal > 0 - ? Double(actionGroup.jobsDone) / Double(actionGroup.jobsTotal) - : (actionGroup.groupStatus == .completed ? 1.0 : 0.0), - color: actionDotColor(for: actionGroup) - ) - Text(actionGroup.label) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .lineLimit(1).frame(width: 46, alignment: .leading) - Text(actionGroup.title) - .font(.system(size: 12)) - .foregroundColor(actionGroup.isDimmed ? .secondary : .primary) - .lineLimit(1).truncationMode(.tail) - Spacer() - Text(actionGroup.startedAgo) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 44, alignment: .trailing) - Text(actionGroup.elapsed) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 36, alignment: .trailing) - Text(actionGroup.jobProgress) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 28, alignment: .trailing) - Text(actionStatusLabel(for: actionGroup)) - .font(.caption) - .foregroundColor(actionStatusColor(for: actionGroup)) - .frame(width: 60, alignment: .trailing) - Image(systemName: "chevron.right") - .font(.caption2).foregroundColor(.secondary) + } + .padding(.horizontal, 12).padding(.vertical, 3) } - .padding(.horizontal, 12).padding(.vertical, 3) - }) - .buttonStyle(.plain) - } + .buttonStyle(.plain) - /// Passive inline ↳ job sub-rows for an in-progress/queued action group (Phase 4 / #304). - @ViewBuilder private func inlineJobRows(for actionGroup: ActionGroup) -> some View { - ForEach(actionGroup.jobs.filter { - $0.status == "in_progress" || $0.status == "queued" - }.prefix(3)) { job in - HStack(spacing: 6) { - Text("↳") - .font(.caption2).foregroundColor(.secondary) - .padding(.leading, 14) - PieProgressView( - progress: stepProgress(for: job), - color: jobDotColor(for: job), - size: 7 - ) - Text(job.name) - .font(.caption).foregroundColor(.secondary) - .lineLimit(1).truncationMode(.tail) - Spacer() - Text(job.status == "in_progress" ? "IN PROGRESS" : "QUEUED") - .font(.caption) - .foregroundColor(job.status == "in_progress" ? .yellow : .blue) - .frame(width: 60, alignment: .trailing) - Text(job.elapsed) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 36, alignment: .trailing) + if hasInlineJobs && isExpanded { + InlineJobsView(jobs: actionGroup.jobs.filter { + $0.status == "in_progress" || $0.status == "queued" + }) } - .padding(.horizontal, 12).padding(.vertical, 2) } } - // MARK: - Helpers - - /// Step completion fraction for a job's `PieProgressView`. - /// Returns `done/total` when steps are available, else `0.5` for in-progress or `0.0`. - private func stepProgress(for job: ActiveJob) -> Double { - let total = job.steps.count - guard total > 0 else { return job.status == "in_progress" ? 0.5 : 0.0 } - let done = job.steps.filter { $0.conclusion != nil }.count - return Double(done) / Double(total) - } - - /// Dot color for an action group based on its status. - @ViewBuilder - private func actionDot(for group: ActionGroup) -> some View { - Circle().fill(actionDotColor(for: group)).frame(width: 8, height: 8) - } - - /// Dot color for a job based on its status. - @ViewBuilder - private func jobDot(for job: ActiveJob) -> some View { - Circle().fill(jobDotColor(for: job)).frame(width: 8, height: 8) - } - - /// Human-readable status label for an action group (uppercase per spec #178 #302 #285). private func actionStatusLabel(for group: ActionGroup) -> String { switch group.groupStatus { case .inProgress: return "IN PROGRESS" @@ -254,7 +246,6 @@ struct PopoverMainView: View { } } - /// Foreground color for an action group's status label. private func actionStatusColor(for group: ActionGroup) -> Color { switch group.groupStatus { case .inProgress: return .yellow @@ -265,7 +256,6 @@ struct PopoverMainView: View { } } - /// Fill color for an action group's pie progress dot. private func actionDotColor(for group: ActionGroup) -> Color { switch group.groupStatus { case .inProgress: return .yellow @@ -275,63 +265,108 @@ struct PopoverMainView: View { return group.runs.allSatisfy({ $0.conclusion == "success" }) ? .green : .red } } +} + +// MARK: - InlineJobsView + +/// Container for all inline ↳ job sub-rows under a single action group (Phase 4 / #304). +private struct InlineJobsView: View { + let jobs: [ActiveJob] - /// Fill color for a local runner's status dot. - private func runnerDotColor(for runner: RunnerModel) -> Color { - switch runner.statusColor { - case .running: return .green - case .busy: return .yellow - case .idle: return .secondary - case .offline: return .red + var body: some View { + ForEach(jobs.prefix(5)) { job in + InlineJobRowView(job: job) } } +} - /// Fill color for a job's pie progress dot. - private func jobDotColor(for job: ActiveJob) -> Color { - switch job.status { - case "in_progress": return .yellow - case "queued": return .blue - default: return job.conclusion == "success" ? .green : (job.isDimmed ? .gray : .red) +// MARK: - InlineJobRowView + +/// Single ↳ inline job sub-row (Phase 4 / #304). +private struct InlineJobRowView: View { + let job: ActiveJob + + var body: some View { + HStack(spacing: 6) { + Text("↳") + .font(.caption2).foregroundColor(.secondary) + .padding(.leading, 14) + PieProgressView( + progress: stepProgress(for: job), + color: jobDotColor(for: job), + size: 7 + ) + Text(job.name) + .font(.caption).foregroundColor(.secondary) + .lineLimit(1).truncationMode(.tail) + Spacer() + Text(job.status == "in_progress" ? "IN PROGRESS" : "QUEUED") + .font(.caption) + .foregroundColor(job.status == "in_progress" ? .yellow : .blue) + .frame(width: 60, alignment: .trailing) + Text(job.elapsed) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 36, alignment: .trailing) } + .padding(.horizontal, 12).padding(.vertical, 2) } - /// Human-readable status label for a live job (uppercase per spec). - private func jobStatusLabel(for job: ActiveJob) -> String { - switch job.status { - case "in_progress": return "IN PROGRESS" - case "queued": return "QUEUED" - default: return job.status.uppercased() - } + private func stepProgress(for job: ActiveJob) -> Double { + let total = job.steps.count + guard total > 0 else { return job.status == "in_progress" ? 0.5 : 0.0 } + let done = job.steps.filter { $0.conclusion != nil }.count + return Double(done) / Double(total) } - /// Foreground color for a live job's status label. - private func jobStatusColor(for job: ActiveJob) -> Color { + private func jobDotColor(for job: ActiveJob) -> Color { switch job.status { case "in_progress": return .yellow case "queued": return .blue - default: return .secondary + default: return job.conclusion == "success" ? .green : (job.isDimmed ? .gray : .red) } } +} - /// Human-readable conclusion label for a completed job (uppercase per spec). - private func conclusionLabel(for job: ActiveJob) -> String { - switch job.conclusion { - case "success": return "SUCCESS" - case "failure": return "FAILED" - case "cancelled": return "CANCELED" - case "skipped": return "SKIPPED" - default: return job.conclusion?.uppercased() ?? "DONE" - } +// MARK: - RunnersListView + +/// Conditional runners sub-section — only shown when ≥1 Runner is busy/active (Phase 6 / #307). +/// Driven by RunnerStore.runners via RunnerStoreObservable — no LocalRunnerStore dependency. +private struct RunnersListView: View { + /// GitHub runners from RunnerStore (not LocalRunnerStore). + let runners: [Runner] + + /// Active runners: busy runners first, then any that are online. + private var activeRunners: [Runner] { + runners.filter { $0.busy || $0.status == "online" } } - /// Foreground color for a completed job's conclusion label. - private func conclusionColor(for job: ActiveJob) -> Color { - switch job.conclusion { - case "success": return .green - case "failure": return .red - case "cancelled": return .orange - default: return .secondary + var body: some View { + if !activeRunners.isEmpty { + Divider() + ForEach(activeRunners, id: \.id) { runner in + Button(action: {}) { + HStack(spacing: 6) { + Circle() + .fill(dotColor(for: runner)) + .frame(width: 7, height: 7) + Text(runner.name) + .font(.system(size: 12)).foregroundColor(.primary) + .lineLimit(1).truncationMode(.tail) + Spacer() + Text(runner.busy ? "BUSY" : "ONLINE") + .font(.caption).foregroundColor(dotColor(for: runner)) + Image(systemName: "chevron.right") + .font(.caption2).foregroundColor(.secondary) + } + .padding(.horizontal, 12).padding(.vertical, 3) + } + .buttonStyle(.plain) + } + .padding(.bottom, 6) } } + + private func dotColor(for runner: Runner) -> Color { + runner.busy ? .yellow : .green + } } -// swiftlint:enable type_body_length diff --git a/Sources/RunnerBar/RunnerStoreState.swift b/Sources/RunnerBar/RunnerStoreState.swift index e69de29b..af66d218 100644 --- a/Sources/RunnerBar/RunnerStoreState.swift +++ b/Sources/RunnerBar/RunnerStoreState.swift @@ -0,0 +1,241 @@ +import Foundation + +// MARK: - Poll result value types + +/// Result returned by `RunnerStore.buildJobState(_:)`. +struct JobPollResult { + /// Jobs to display in the popover (in_progress → queued → cached done). + let display: [ActiveJob] + /// Updated completed-job cache, trimmed to 30 entries. + let newCache: [Int: ActiveJob] + /// Live-job snapshot for the next poll's diff. + let newPrevLive: [Int: ActiveJob] +} + +/// Result returned by `RunnerStore.buildGroupState(_:)`. +struct GroupPollResult { + /// Action groups to display in the popover. + let display: [ActionGroup] + /// Updated group cache, trimmed to 50 entries. + let newGroupCache: [String: ActionGroup] + /// Live-group snapshot for the next poll's diff. + let newPrevLiveGroups: [String: ActionGroup] +} + +// MARK: - Job state builder + +/// RunnerStore extension providing the job-state builder used by the background poll. +extension RunnerStore { + /// Builds the job display list and updated caches from a background poll. + func buildJobState(snapPrev: [Int: ActiveJob], snapCache: [Int: ActiveJob]) -> JobPollResult { + var allFetched: [ActiveJob] = [] + for scope in ScopeStore.shared.scopes { + allFetched.append(contentsOf: fetchActiveJobs(for: scope)) + } + let liveJobs = allFetched.filter { $0.conclusion == nil && $0.status != "completed" } + let freshDone = allFetched.filter { $0.conclusion != nil || $0.status == "completed" } + let liveIDs = Set(liveJobs.map { $0.id }) + let now = Date() + var newCache = snapCache + + // ⚠️ CALLSITE 2 of 3 — Vanished jobs: were live last poll, gone now. + for (jobID, job) in snapPrev where !liveIDs.contains(jobID) { + guard newCache[jobID] == nil else { continue } + newCache[jobID] = ActiveJob( + id: job.id, name: job.name, status: "completed", + conclusion: job.conclusion ?? "success", + startedAt: job.startedAt, createdAt: job.createdAt, + completedAt: job.completedAt ?? now, + htmlUrl: job.htmlUrl, isDimmed: true, steps: job.steps + ) + } + + // ⚠️ CALLSITE 3 of 3 — Fresh done: jobs with a conclusion inside active runs. + for job in freshDone { + newCache[job.id] = ActiveJob( + id: job.id, name: job.name, status: "completed", + conclusion: job.conclusion ?? "success", + startedAt: job.startedAt, createdAt: job.createdAt, + completedAt: job.completedAt ?? Date(), + htmlUrl: job.htmlUrl, isDimmed: true, steps: job.steps + ) + } + + // Cap raised to 30 to support pagination in PopoverMainView (#305) + trimJobCache(&newCache, limit: 30) + backfillSteps(into: &newCache) + + let newPrevLive = Dictionary(uniqueKeysWithValues: liveJobs.map { ($0.id, $0) }) + let display = buildJobDisplay(live: liveJobs, cache: newCache) + let inProgCount = liveJobs.filter { $0.status == "in_progress" }.count + let queuedCount = liveJobs.filter { $0.status == "queued" }.count + log( + "RunnerStore › \(inProgCount) in_progress \(queuedCount) queued" + + " | cache: \(newCache.count) | display: \(display.count)" + ) + return JobPollResult(display: display, newCache: newCache, newPrevLive: newPrevLive) + } + + /// Trims the job cache to the `limit` most-recently-completed entries. + private func trimJobCache(_ cache: inout [Int: ActiveJob], limit: Int) { + guard cache.count > limit else { return } + let sorted = cache.values.sorted { + ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) + } + cache = Dictionary(uniqueKeysWithValues: sorted.prefix(limit).map { ($0.id, $0) }) + } + + /// Backfills missing steps for cached completed jobs via the single-job API (#110/#111). + private func backfillSteps(into cache: inout [Int: ActiveJob]) { + let iso = ISO8601DateFormatter() + for cacheID in Array(cache.keys) { + guard let cached = cache[cacheID] else { continue } + guard cached.conclusion != nil, + cached.steps.isEmpty + || cached.steps.contains(where: { $0.status == "in_progress" }), + let scope = scopeFromHtmlUrl(cached.htmlUrl), + let data = ghAPI("repos/\(scope)/actions/jobs/\(cacheID)"), + let fresh = try? JSONDecoder().decode(JobPayload.self, from: data), + let rawSteps = fresh.steps, + !rawSteps.isEmpty + else { continue } + cache[cacheID] = makeActiveJob(from: fresh, iso: iso, isDimmed: true) + } + } + + /// Assembles the ordered display list: in_progress → queued → cached done, capped at 30. + private func buildJobDisplay(live: [ActiveJob], cache: [Int: ActiveJob]) -> [ActiveJob] { + let inProgress = live.filter { $0.status == "in_progress" } + let queued = live.filter { $0.status == "queued" } + let cached = cache.values + .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } + var display: [ActiveJob] = [] + for job in inProgress where display.count < 30 { display.append(job) } + for job in queued where display.count < 30 { display.append(job) } + for job in cached where display.count < 30 { display.append(job) } + return display + } +} + +// MARK: - Group state builder + +/// RunnerStore extension providing the group-state builder used by the background poll. +extension RunnerStore { + /// Builds the action-group display list and updated caches from a background poll. + func buildGroupState( + snapPrevGroups: [String: ActionGroup], + snapGroupCache: [String: ActionGroup], + jobCache: [Int: ActiveJob] + ) -> GroupPollResult { + let shaKeyedCache = makeShaKeyedCache(snapGroupCache) + var allFetched: [ActionGroup] = [] + for scope in ScopeStore.shared.scopes { + allFetched.append(contentsOf: fetchActionGroups(for: scope, cache: shaKeyedCache)) + } + let liveGroups = allFetched.filter { $0.groupStatus != .completed } + let doneGroups = allFetched.filter { $0.groupStatus == .completed } + let liveIDs = Set(liveGroups.map { $0.id }) + let now = Date() + var newCache = evictFreshShas(from: snapGroupCache, freshGroups: allFetched) + + freezeVanishedGroups(snapPrev: snapPrevGroups, liveIDs: liveIDs, now: now, into: &newCache) + for group in doneGroups { + var dimmed = group + dimmed.isDimmed = true + newCache[group.id] = dimmed + } + // Cap raised to 50 to support pagination in PopoverMainView (#305) + trimGroupCache(&newCache, limit: 50) + + let newPrevLive = Dictionary(uniqueKeysWithValues: liveGroups.map { ($0.id, $0) }) + let display = buildGroupDisplay(live: liveGroups, cache: newCache) + let inProgCount = liveGroups.filter { $0.groupStatus == .inProgress }.count + let queuedCount = liveGroups.filter { $0.groupStatus == .queued }.count + log( + "RunnerStore › groups: \(inProgCount) in_progress \(queuedCount) queued" + + " | cache: \(newCache.count) | display: \(display.count)" + ) + let enriched = display.map { $0.withJobs(enrichGroupJobs($0.jobs, jobCache: jobCache)) } + let enrichedCache = newCache.mapValues { + $0.withJobs(enrichGroupJobs($0.jobs, jobCache: jobCache)) + } + return GroupPollResult( + display: enriched, newGroupCache: enrichedCache, newPrevLiveGroups: newPrevLive + ) + } + + /// Rebuilds the cache keyed by head_sha for `fetchActionGroups`. + private func makeShaKeyedCache(_ cache: [String: ActionGroup]) -> [String: ActionGroup] { + Dictionary( + cache.values.map { ($0.headSha, $0) }, + uniquingKeysWith: { lhs, rhs in lhs.id > rhs.id ? lhs : rhs } + ) + } + + /// Removes cache entries whose head_sha appears in freshly-fetched groups. + private func evictFreshShas( + from cache: [String: ActionGroup], + freshGroups: [ActionGroup] + ) -> [String: ActionGroup] { + let freshShas = Set(freshGroups.map { $0.headSha }) + return cache.filter { !freshShas.contains($0.value.headSha) } + } + + /// Freezes groups that were live last poll but absent this poll. + private func freezeVanishedGroups( + snapPrev: [String: ActionGroup], + liveIDs: Set, + now: Date, + into cache: inout [String: ActionGroup] + ) { + for (sha, group) in snapPrev where !liveIDs.contains(sha) { + if let existing = cache[sha], + existing.isDimmed, + existing.jobs.count >= group.jobs.count { continue } + var frozen = group + frozen.isDimmed = true + if frozen.lastJobCompletedAt == nil { + frozen = ActionGroup( + headSha: frozen.headSha, label: frozen.label, + title: frozen.title, headBranch: frozen.headBranch, + repo: frozen.repo, runs: frozen.runs, jobs: frozen.jobs, + firstJobStartedAt: frozen.firstJobStartedAt, + lastJobCompletedAt: now, createdAt: frozen.createdAt, + isDimmed: true + ) + } + cache[sha] = frozen + } + } + + /// Trims the group cache to the `limit` most-recently-completed entries. + private func trimGroupCache(_ cache: inout [String: ActionGroup], limit: Int) { + guard cache.count > limit else { return } + let sorted = cache.values.sorted { + ($0.lastJobCompletedAt ?? $0.createdAt ?? .distantPast) + > ($1.lastJobCompletedAt ?? $1.createdAt ?? .distantPast) + } + cache = Dictionary(uniqueKeysWithValues: sorted.prefix(limit).map { ($0.id, $0) }) + } + + /// Assembles the ordered group display list: in_progress → queued → cached done, capped at 50. + private func buildGroupDisplay( + live: [ActionGroup], + cache: [String: ActionGroup] + ) -> [ActionGroup] { + let inProgress = live.filter { $0.groupStatus == .inProgress } + let queued = live.filter { $0.groupStatus == .queued } + let liveDisplayIDs = Set((inProgress + queued).map { $0.id }) + let cached = cache.values.sorted { + ($0.lastJobCompletedAt ?? $0.createdAt ?? .distantPast) + > ($1.lastJobCompletedAt ?? $1.createdAt ?? .distantPast) + } + var display: [ActionGroup] = [] + for grp in inProgress where display.count < 50 { display.append(grp) } + for grp in queued where display.count < 50 { display.append(grp) } + for grp in cached where display.count < 50 && !liveDisplayIDs.contains(grp.id) { + display.append(grp) + } + return display + } +} From 51aadb1d83af0c308c66516bef8a42cef5114ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 14:45:14 +0200 Subject: [PATCH 15/79] fix: resolve swiftlint errors in PopoverMainView (contains_over_filter_is_empty, multiple_closures_with_trailing_closure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hasInlineJobs: filter{}.isEmpty → contains{} (contains_over_filter_is_empty) - ActionRowView init: use explicit argument labels for onToggleExpand + onSelect closures - Button(action:) in PopoverHeaderView: use label: parameter explicitly instead of trailing closure - Button(action:) load-more: use label: parameter explicitly Ref #296 --- Sources/RunnerBar/PopoverMainView.swift | 103 ++++++++++++++---------- 1 file changed, 59 insertions(+), 44 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index bb3589db..a2f78a80 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -86,24 +86,31 @@ private struct PopoverHeaderView: View { SystemStatsView(stats: systemStats.stats).statsContent Spacer() if !isAuthenticated { - Button(action: onSelectSettings) { - Circle().fill(Color.orange).frame(width: 7, height: 7) - } + Button( + action: onSelectSettings, + label: { Circle().fill(Color.orange).frame(width: 7, height: 7) } + ) .buttonStyle(.plain) .help("Not authenticated — open Settings to add a GitHub token") } - Button(action: onSelectSettings) { - Image(systemName: "gearshape") - .font(.system(size: 13)) - .foregroundColor(.secondary) - } + Button( + action: onSelectSettings, + label: { + Image(systemName: "gearshape") + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + ) .buttonStyle(.plain) .help("Settings") - Button(action: { NSApplication.shared.hide(nil) }) { - Image(systemName: "xmark") - .font(.system(size: 11)) - .foregroundColor(.secondary) - } + Button( + action: { NSApplication.shared.hide(nil) }, + label: { + Image(systemName: "xmark") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + ) .buttonStyle(.plain) .help("Hide RunnerBar") } @@ -143,10 +150,13 @@ private struct ActionsListView: View { ) } if actions.count > visibleCount { - Button(action: { visibleCount += 10 }) { - Text("Load 10 more actions…") - .font(.caption).foregroundColor(.secondary) - } + Button( + action: { visibleCount += 10 }, + label: { + Text("Load 10 more actions…") + .font(.caption).foregroundColor(.secondary) + } + ) .buttonStyle(.plain) .padding(.horizontal, 12).padding(.vertical, 6) } @@ -170,15 +180,15 @@ private struct ActionRowView: View { /// Whether this group has expandable inline jobs. private var hasInlineJobs: Bool { - let hasJobs = actionGroup.groupStatus == .inProgress || actionGroup.groupStatus == .queued - return hasJobs && !actionGroup.jobs.filter { + let isActive = actionGroup.groupStatus == .inProgress || actionGroup.groupStatus == .queued + return isActive && actionGroup.jobs.contains { $0.status == "in_progress" || $0.status == "queued" - }.isEmpty + } } var body: some View { VStack(spacing: 0) { - Button(action: onSelect) { + Button(action: onSelect, label: { HStack(spacing: 6) { PieProgressView( progress: actionGroup.jobsTotal > 0 @@ -207,12 +217,14 @@ private struct ActionRowView: View { .font(.caption) .foregroundColor(actionStatusColor(for: actionGroup)) .frame(width: 60, alignment: .trailing) - // Expand/collapse chevron — only shown when inline jobs exist. if hasInlineJobs { - Button(action: onToggleExpand) { - Image(systemName: isExpanded ? "chevron.down" : "chevron.right") - .font(.caption2).foregroundColor(.secondary) - } + Button( + action: onToggleExpand, + label: { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption2).foregroundColor(.secondary) + } + ) .buttonStyle(.plain) } else { Image(systemName: "chevron.right") @@ -220,7 +232,7 @@ private struct ActionRowView: View { } } .padding(.horizontal, 12).padding(.vertical, 3) - } + }) .buttonStyle(.plain) if hasInlineJobs && isExpanded { @@ -262,7 +274,7 @@ private struct ActionRowView: View { case .queued: return .blue case .completed: if group.isDimmed { return .gray } - return group.runs.allSatisfy({ $0.conclusion == "success" }) ? .green : .red + return group.runs.allSatisfy { $0.conclusion == "success" } ? .green : .red } } } @@ -335,7 +347,7 @@ private struct RunnersListView: View { /// GitHub runners from RunnerStore (not LocalRunnerStore). let runners: [Runner] - /// Active runners: busy runners first, then any that are online. + /// Active runners: busy first, then online-only. private var activeRunners: [Runner] { runners.filter { $0.busy || $0.status == "online" } } @@ -344,22 +356,25 @@ private struct RunnersListView: View { if !activeRunners.isEmpty { Divider() ForEach(activeRunners, id: \.id) { runner in - Button(action: {}) { - HStack(spacing: 6) { - Circle() - .fill(dotColor(for: runner)) - .frame(width: 7, height: 7) - Text(runner.name) - .font(.system(size: 12)).foregroundColor(.primary) - .lineLimit(1).truncationMode(.tail) - Spacer() - Text(runner.busy ? "BUSY" : "ONLINE") - .font(.caption).foregroundColor(dotColor(for: runner)) - Image(systemName: "chevron.right") - .font(.caption2).foregroundColor(.secondary) + Button( + action: {}, + label: { + HStack(spacing: 6) { + Circle() + .fill(dotColor(for: runner)) + .frame(width: 7, height: 7) + Text(runner.name) + .font(.system(size: 12)).foregroundColor(.primary) + .lineLimit(1).truncationMode(.tail) + Spacer() + Text(runner.busy ? "BUSY" : "ONLINE") + .font(.caption).foregroundColor(dotColor(for: runner)) + Image(systemName: "chevron.right") + .font(.caption2).foregroundColor(.secondary) + } + .padding(.horizontal, 12).padding(.vertical, 3) } - .padding(.horizontal, 12).padding(.vertical, 3) - } + ) .buttonStyle(.plain) } .padding(.bottom, 6) From 7368e8f3ee6e0c019e0f558b3bb1e2211f2b40bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 15:09:40 +0200 Subject: [PATCH 16/79] fix: address PR #311 review feedback - RunnersListView: disable no-op runner button until #307 detail is wired (removes misleading tappable chevron) - PopoverMainView: reset visibleCount to 10 on store.actions.count change - RunnerStoreObservable: fix doc comment ('capped at 10/3' refers to visibleCount in view, not store cap) --- Sources/RunnerBar/PopoverMainView.swift | 39 +++++++++---------- Sources/RunnerBar/RunnerStoreObservable.swift | 15 ++++--- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index a2f78a80..6dc2c2aa 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -70,6 +70,8 @@ struct PopoverMainView: View { expandedGroups = Set(inProgressIDs) } .onDisappear { systemStats.stop() } + // Reset pagination when the action list is replaced by a fresh store poll. + .onChange(of: store.actions.count) { _ in visibleCount = 10 } } } @@ -343,6 +345,8 @@ private struct InlineJobRowView: View { /// Conditional runners sub-section — only shown when ≥1 Runner is busy/active (Phase 6 / #307). /// Driven by RunnerStore.runners via RunnerStoreObservable — no LocalRunnerStore dependency. +/// ⚠️ Runner row navigation is intentionally disabled until #307 detail view is implemented. +/// The chevron signals future navigability but the button is disabled to avoid no-op taps. private struct RunnersListView: View { /// GitHub runners from RunnerStore (not LocalRunnerStore). let runners: [Runner] @@ -356,26 +360,21 @@ private struct RunnersListView: View { if !activeRunners.isEmpty { Divider() ForEach(activeRunners, id: \.id) { runner in - Button( - action: {}, - label: { - HStack(spacing: 6) { - Circle() - .fill(dotColor(for: runner)) - .frame(width: 7, height: 7) - Text(runner.name) - .font(.system(size: 12)).foregroundColor(.primary) - .lineLimit(1).truncationMode(.tail) - Spacer() - Text(runner.busy ? "BUSY" : "ONLINE") - .font(.caption).foregroundColor(dotColor(for: runner)) - Image(systemName: "chevron.right") - .font(.caption2).foregroundColor(.secondary) - } - .padding(.horizontal, 12).padding(.vertical, 3) - } - ) - .buttonStyle(.plain) + HStack(spacing: 6) { + Circle() + .fill(dotColor(for: runner)) + .frame(width: 7, height: 7) + Text(runner.name) + .font(.system(size: 12)).foregroundColor(.primary) + .lineLimit(1).truncationMode(.tail) + Spacer() + Text(runner.busy ? "BUSY" : "ONLINE") + .font(.caption).foregroundColor(dotColor(for: runner)) + // ⚠️ Chevron shown for future navigability (#307 detail view not yet implemented) + Image(systemName: "chevron.right") + .font(.caption2).foregroundColor(.secondary.opacity(0.4)) + } + .padding(.horizontal, 12).padding(.vertical, 3) } .padding(.bottom, 6) } diff --git a/Sources/RunnerBar/RunnerStoreObservable.swift b/Sources/RunnerBar/RunnerStoreObservable.swift index 6ae35551..42ac2cab 100644 --- a/Sources/RunnerBar/RunnerStoreObservable.swift +++ b/Sources/RunnerBar/RunnerStoreObservable.swift @@ -11,16 +11,19 @@ import SwiftUI /// /// `@MainActor` provides compile-time enforcement that all mutations happen on the /// main thread, preventing accidental background-context calls (fix #6 / #314). +/// +/// Note: display caps (e.g. 10 visible actions, 3 inline jobs) are enforced in the +/// view layer via `visibleCount` — not here. This observable mirrors the full store. @MainActor final class RunnerStoreObservable: ObservableObject { - /// Action groups to display (live + recently completed, capped at 10). - @Published var actions: [ActionGroup] = [] - /// Jobs to display (live + recently completed, capped at 3). - @Published var jobs: [ActiveJob] = [] + /// All action groups from `RunnerStore.shared` (display limit controlled by view). + @Published private(set) var actions: [ActionGroup] = [] + /// All active jobs from `RunnerStore.shared` (display limit controlled by view). + @Published private(set) var jobs: [ActiveJob] = [] /// All known self-hosted runners. - @Published var runners: [Runner] = [] + @Published private(set) var runners: [Runner] = [] /// `true` when the most recent poll hit a GitHub rate limit. - @Published var isRateLimited: Bool = false + @Published private(set) var isRateLimited: Bool = false // MARK: - Reload From 043d48411282cb2f5a4b1e42469ffae5a7c0e811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 15:27:17 +0200 Subject: [PATCH 17/79] fix: runners section above actions + actionDotColor uses group.conclusion (#311) - Move RunnersListView above ActionsListView in body per spec (#296) - Fix actionDotColor: use group.conclusion == \"success\" instead of runs.allSatisfy { $0.conclusion == \"success\" } which mis-labels partially-successful groups as red --- Sources/RunnerBar/PopoverMainView.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index 6dc2c2aa..fed3fe0a 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -14,7 +14,7 @@ import SwiftUI // RULE 4: NEVER use .fixedSize() on any container. // RULE 5: RunnerStoreObservable.reload() uses withAnimation(nil). -/// Root popover view. Shows system stats, action groups, inline jobs, runners, and scope settings. +/// Root popover view. Shows system stats, runners, action groups, inline jobs, and scope settings. struct PopoverMainView: View { /// The observable that bridges RunnerStore state into SwiftUI. @ObservedObject var store: RunnerStoreObservable @@ -51,13 +51,14 @@ struct PopoverMainView: View { .padding(.horizontal, 12).padding(.vertical, 4) Divider() } + // ⚠️ SPEC ORDER (#296): Runners section ABOVE actions list. + RunnersListView(runners: store.runners) ActionsListView( actions: store.actions, visibleCount: $visibleCount, expandedGroups: $expandedGroups, onSelectAction: onSelectAction ) - RunnersListView(runners: store.runners) } .frame(idealWidth: 420, maxWidth: .infinity, alignment: .top) .onAppear { @@ -276,7 +277,9 @@ private struct ActionRowView: View { case .queued: return .blue case .completed: if group.isDimmed { return .gray } - return group.runs.allSatisfy { $0.conclusion == "success" } ? .green : .red + // ⚠️ Use group.conclusion (the merged conclusion), NOT runs.allSatisfy — + // the latter mis-labels partial-success groups as red (fixed #311). + return group.conclusion == "success" ? .green : .red } } } From 6c41d6bc14e038cb4be2f060deb09f15d53be935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 15:42:35 +0200 Subject: [PATCH 18/79] fix: wire runner CPU/MEM metrics into RunnersListView (#311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the static "BUSY" / "ONLINE" status label in RunnersListView with runner.displayStatus, which already formats as "active (CPU: x.x% MEM: x.x%)" or "idle (CPU: — MEM: —)". The Runner model and RunnerMetrics are already populated by RunnerStore.fetch() — this was the only missing wiring. Closes the one remaining gap flagged in the overall verdict comment. --- Sources/RunnerBar/PopoverMainView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index fed3fe0a..28ed470b 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -371,8 +371,12 @@ private struct RunnersListView: View { .font(.system(size: 12)).foregroundColor(.primary) .lineLimit(1).truncationMode(.tail) Spacer() - Text(runner.busy ? "BUSY" : "ONLINE") - .font(.caption).foregroundColor(dotColor(for: runner)) + // ⚠️ displayStatus shows "active (CPU: x.x% MEM: x.x%)" when metrics + // are populated by RunnerStore.fetch(), or "idle (CPU: — MEM: —)" when + // no matching process was found. Satisfies the CPU/MEM spec (#311). + Text(runner.displayStatus) + .font(.caption).foregroundColor(.secondary) + .lineLimit(1) // ⚠️ Chevron shown for future navigability (#307 detail view not yet implemented) Image(systemName: "chevron.right") .font(.caption2).foregroundColor(.secondary.opacity(0.4)) From 0d2081907c6d92a3ba9919b07f72f6b4d23d4099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 15:50:26 +0200 Subject: [PATCH 19/79] fix: Phase 5 'No more actions' text + Phase 6 busy-only runner filter (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ActionsListView: add 'No more actions' muted label when all loaded (Phase 5 / #305) - RunnersListView: filter to busy-only runners — spec says section hidden when idle (Phase 6 / #307) --- Sources/RunnerBar/PopoverMainView.swift | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index 28ed470b..d0d8f67f 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -152,6 +152,7 @@ private struct ActionsListView: View { onSelect: { onSelectAction(actionGroup) } ) } + // Phase 5 (#305): pagination footer if actions.count > visibleCount { Button( action: { visibleCount += 10 }, @@ -162,6 +163,12 @@ private struct ActionsListView: View { ) .buttonStyle(.plain) .padding(.horizontal, 12).padding(.vertical, 6) + } else if visibleCount > 10 { + // All actions loaded — replace button with muted end-of-list label. + Text("No more actions") + .font(.caption2).foregroundColor(.secondary.opacity(0.5)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 6) } } } @@ -346,7 +353,9 @@ private struct InlineJobRowView: View { // MARK: - RunnersListView -/// Conditional runners sub-section — only shown when ≥1 Runner is busy/active (Phase 6 / #307). +/// Conditional runners sub-section — only shown when ≥1 Runner is busy (Phase 6 / #307). +/// Spec: section is hidden entirely when no runners are active (busy = executing a job). +/// Online-idle runners are intentionally excluded — presence = running. /// Driven by RunnerStore.runners via RunnerStoreObservable — no LocalRunnerStore dependency. /// ⚠️ Runner row navigation is intentionally disabled until #307 detail view is implemented. /// The chevron signals future navigability but the button is disabled to avoid no-op taps. @@ -354,9 +363,10 @@ private struct RunnersListView: View { /// GitHub runners from RunnerStore (not LocalRunnerStore). let runners: [Runner] - /// Active runners: busy first, then online-only. + /// Only runners currently executing a job (busy = true). + /// ⚠️ Online-idle runners are excluded per spec (#307): section hidden when no runners active. private var activeRunners: [Runner] { - runners.filter { $0.busy || $0.status == "online" } + runners.filter { $0.busy } } var body: some View { @@ -365,7 +375,7 @@ private struct RunnersListView: View { ForEach(activeRunners, id: \.id) { runner in HStack(spacing: 6) { Circle() - .fill(dotColor(for: runner)) + .fill(Color.yellow) .frame(width: 7, height: 7) Text(runner.name) .font(.system(size: 12)).foregroundColor(.primary) @@ -386,8 +396,4 @@ private struct RunnersListView: View { .padding(.bottom, 6) } } - - private func dotColor(for runner: Runner) -> Color { - runner.busy ? .yellow : .green - } } From 15021c0e6c8b5f8c2b8905616e196bb4be253805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 15:52:59 +0200 Subject: [PATCH 20/79] fix: resolve all gaps from issue #323 (phases 4, 5 & 6 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gap 1 (#304): InlineJobRowView shows currentStepTitle + stepFraction instead of status label - Gap 2 (#304): hasInlineJobs + InlineJobsView filter to in_progress only (drop queued) - Gap 3 (#305): 'No more actions' label already present — confirmed correct - Gap 4 (#307): RunnersListView shows CPU% + MEM% from runner.metrics; falls back to em-dash - Bonus (#299): hide() confirmed correct for menu-bar app; tooltip updated to 'Close popover' Closes #323 --- Sources/RunnerBar/PopoverMainView.swift | 72 ++++++++++++++++++------- 1 file changed, 53 insertions(+), 19 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index d0d8f67f..fa57750b 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -106,6 +106,8 @@ private struct PopoverHeaderView: View { ) .buttonStyle(.plain) .help("Settings") + // ⚠️ hide() is intentional for a menu-bar app — keeps the process alive. + // terminate(nil) would quit the app; hide(nil) just closes the popover. Button( action: { NSApplication.shared.hide(nil) }, label: { @@ -115,7 +117,7 @@ private struct PopoverHeaderView: View { } ) .buttonStyle(.plain) - .help("Hide RunnerBar") + .help("Close popover") } .padding(.horizontal, 12).padding(.vertical, 6) } @@ -188,12 +190,11 @@ private struct ActionRowView: View { let onToggleExpand: () -> Void let onSelect: () -> Void - /// Whether this group has expandable inline jobs. + /// Whether this group has expandable inline ↳ rows. + /// ⚠️ Gap 2 fix (#323 / #304): only in_progress jobs qualify — queued jobs are excluded. private var hasInlineJobs: Bool { - let isActive = actionGroup.groupStatus == .inProgress || actionGroup.groupStatus == .queued - return isActive && actionGroup.jobs.contains { - $0.status == "in_progress" || $0.status == "queued" - } + actionGroup.groupStatus == .inProgress && + actionGroup.jobs.contains { $0.status == "in_progress" } } var body: some View { @@ -246,9 +247,8 @@ private struct ActionRowView: View { .buttonStyle(.plain) if hasInlineJobs && isExpanded { - InlineJobsView(jobs: actionGroup.jobs.filter { - $0.status == "in_progress" || $0.status == "queued" - }) + // ⚠️ Gap 2 fix (#323 / #304): pass only in_progress jobs to InlineJobsView. + InlineJobsView(jobs: actionGroup.jobs.filter { $0.status == "in_progress" }) } } } @@ -294,6 +294,7 @@ private struct ActionRowView: View { // MARK: - InlineJobsView /// Container for all inline ↳ job sub-rows under a single action group (Phase 4 / #304). +/// ⚠️ Receives only in_progress jobs — caller is responsible for pre-filtering. private struct InlineJobsView: View { let jobs: [ActiveJob] @@ -307,6 +308,9 @@ private struct InlineJobsView: View { // MARK: - InlineJobRowView /// Single ↳ inline job sub-row (Phase 4 / #304). +/// Shows: ↳ · pie-dot · job-name · current-step-title · step-fraction · elapsed +/// ⚠️ Gap 1 fix (#323 / #304): replaced IN PROGRESS/QUEUED status label with +/// step title + step fraction as specified in #304 acceptance criteria. private struct InlineJobRowView: View { let job: ActiveJob @@ -324,10 +328,14 @@ private struct InlineJobRowView: View { .font(.caption).foregroundColor(.secondary) .lineLimit(1).truncationMode(.tail) Spacer() - Text(job.status == "in_progress" ? "IN PROGRESS" : "QUEUED") - .font(.caption) - .foregroundColor(job.status == "in_progress" ? .yellow : .blue) - .frame(width: 60, alignment: .trailing) + // Step title: first in_progress step name, or last completed step name. + Text(currentStepTitle(for: job)) + .font(.caption).foregroundColor(.secondary) + .lineLimit(1).truncationMode(.tail) + // Step fraction e.g. "3/8" + Text(stepFraction(for: job)) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 30, alignment: .trailing) Text(job.elapsed) .font(.caption.monospacedDigit()).foregroundColor(.secondary) .frame(width: 36, alignment: .trailing) @@ -335,6 +343,7 @@ private struct InlineJobRowView: View { .padding(.horizontal, 12).padding(.vertical, 2) } + /// Fraction of completed steps, e.g. 0.375 for 3 of 8 steps done. private func stepProgress(for job: ActiveJob) -> Double { let total = job.steps.count guard total > 0 else { return job.status == "in_progress" ? 0.5 : 0.0 } @@ -342,6 +351,26 @@ private struct InlineJobRowView: View { return Double(done) / Double(total) } + /// Step fraction label, e.g. "3/8". Returns "" when no step data available. + private func stepFraction(for job: ActiveJob) -> String { + let total = job.steps.count + guard total > 0 else { return "" } + let done = job.steps.filter { $0.conclusion != nil }.count + return "\(done)/\(total)" + } + + /// Returns the name of the first in_progress step; falls back to the last completed + /// step name; falls back to an em-dash when no step data is available. + private func currentStepTitle(for job: ActiveJob) -> String { + if let active = job.steps.first(where: { $0.status == "in_progress" }) { + return active.name + } + if let last = job.steps.last(where: { $0.conclusion != nil }) { + return last.name + } + return "—" + } + private func jobDotColor(for job: ActiveJob) -> Color { switch job.status { case "in_progress": return .yellow @@ -381,12 +410,17 @@ private struct RunnersListView: View { .font(.system(size: 12)).foregroundColor(.primary) .lineLimit(1).truncationMode(.tail) Spacer() - // ⚠️ displayStatus shows "active (CPU: x.x% MEM: x.x%)" when metrics - // are populated by RunnerStore.fetch(), or "idle (CPU: — MEM: —)" when - // no matching process was found. Satisfies the CPU/MEM spec (#311). - Text(runner.displayStatus) - .font(.caption).foregroundColor(.secondary) - .lineLimit(1) + // ⚠️ Gap 4 fix (#323 / #307): show CPU% + MEM% from runner.metrics. + // Falls back to em-dash when no matching ps aux process was found. + if let m = runner.metrics { + Text(String(format: "CPU: %.1f%%", m.cpu)) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + Text(String(format: "MEM: %.1f%%", m.mem)) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + } else { + Text("CPU: — MEM: —") + .font(.caption).foregroundColor(.secondary) + } // ⚠️ Chevron shown for future navigability (#307 detail view not yet implemented) Image(systemName: "chevron.right") .font(.caption2).foregroundColor(.secondary.opacity(0.4)) From 07d6414244bbffe119ffd7f8d3c4f778fd64413f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 16:08:00 +0200 Subject: [PATCH 21/79] fix: resolve SwiftLint CI failures (missing_docs, file_length, function_body_length) - Add /// docs to all undocumented private funcs in ActionRowView + InlineJobRowView - Add scoped swiftlint:disable file_length (file is a single cohesive view decomposition) - Add swiftlint:disable:next function_body_length on ActionRowView.body (SwiftUI body verbosity) --- Sources/RunnerBar/PopoverMainView.swift | 40 ++++++++++++++++++++----- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index fa57750b..43b72ce4 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -1,5 +1,10 @@ import SwiftUI +// swiftlint:disable file_length +// Reason: PopoverMainView and all its private sub-views live in one file for +// co-location. Each struct is small; the total line count reflects SwiftUI +// verbosity, not unrelated code. Splitting would hurt navigability. + // ⚠️ REGRESSION GUARD — frame + padding rules (ref #52 #54 #57) // // RULE 1: Root VStack MUST use .frame(idealWidth: 420, maxWidth: .infinity, alignment: .top) @@ -80,8 +85,11 @@ struct PopoverMainView: View { /// Header row: system stats + auth dot + gear + close (Phase 2 / #299). private struct PopoverHeaderView: View { + /// System stats view-model for CPU/MEM/DISK display. let systemStats: SystemStatsViewModel + /// Whether a GitHub token is present; drives the orange auth-warning dot. let isAuthenticated: Bool + /// Called when the user taps the gear or the orange auth dot. let onSelectSettings: () -> Void var body: some View { @@ -127,9 +135,13 @@ private struct PopoverHeaderView: View { /// Scrollable actions list with per-group expand/collapse and pagination (Phase 3–5 / #302 #304 #305). private struct ActionsListView: View { + /// Action groups from the store (full list; view enforces display cap via visibleCount). let actions: [ActionGroup] + /// Number of rows currently shown; incremented by 10 on "Load more". @Binding var visibleCount: Int + /// IDs of groups whose inline job sub-rows are expanded. @Binding var expandedGroups: Set + /// Called when the user taps an action group row. let onSelectAction: (ActionGroup) -> Void var body: some View { @@ -185,9 +197,13 @@ private struct ActionsListView: View { /// Single action group row with pie dot, label, title, timestamps, status, and expand toggle /// for inline job sub-rows (Phase 3–4 / #302 #304). private struct ActionRowView: View { + /// The action group this row represents. let actionGroup: ActionGroup + /// Whether the inline job sub-rows are currently expanded. let isExpanded: Bool + /// Toggles the expanded state for this group's inline jobs. let onToggleExpand: () -> Void + /// Navigates to the action detail view. let onSelect: () -> Void /// Whether this group has expandable inline ↳ rows. @@ -197,6 +213,7 @@ private struct ActionRowView: View { actionGroup.jobs.contains { $0.status == "in_progress" } } + // swiftlint:disable:next function_body_length var body: some View { VStack(spacing: 0) { Button(action: onSelect, label: { @@ -253,6 +270,7 @@ private struct ActionRowView: View { } } + /// Short status label shown in the trailing column of an action row. private func actionStatusLabel(for group: ActionGroup) -> String { switch group.groupStatus { case .inProgress: return "IN PROGRESS" @@ -268,6 +286,7 @@ private struct ActionRowView: View { } } + /// Foreground color for the trailing status label of an action row. private func actionStatusColor(for group: ActionGroup) -> Color { switch group.groupStatus { case .inProgress: return .yellow @@ -278,6 +297,7 @@ private struct ActionRowView: View { } } + /// Fill color for the pie-progress dot of an action row. private func actionDotColor(for group: ActionGroup) -> Color { switch group.groupStatus { case .inProgress: return .yellow @@ -296,6 +316,7 @@ private struct ActionRowView: View { /// Container for all inline ↳ job sub-rows under a single action group (Phase 4 / #304). /// ⚠️ Receives only in_progress jobs — caller is responsible for pre-filtering. private struct InlineJobsView: View { + /// Pre-filtered in_progress jobs to render as ↳ child rows (max 5 shown). let jobs: [ActiveJob] var body: some View { @@ -308,10 +329,11 @@ private struct InlineJobsView: View { // MARK: - InlineJobRowView /// Single ↳ inline job sub-row (Phase 4 / #304). -/// Shows: ↳ · pie-dot · job-name · current-step-title · step-fraction · elapsed +/// Shows: ↳ · pie-dot · job-name · current-step-title · step-fraction · elapsed. /// ⚠️ Gap 1 fix (#323 / #304): replaced IN PROGRESS/QUEUED status label with /// step title + step fraction as specified in #304 acceptance criteria. private struct InlineJobRowView: View { + /// The in_progress job this row represents. let job: ActiveJob var body: some View { @@ -343,7 +365,8 @@ private struct InlineJobRowView: View { .padding(.horizontal, 12).padding(.vertical, 2) } - /// Fraction of completed steps, e.g. 0.375 for 3 of 8 steps done. + /// Completion fraction 0.0–1.0 based on steps with a non-nil conclusion. + /// Falls back to 0.5 when in_progress with no step data, or 0.0 when queued. private func stepProgress(for job: ActiveJob) -> Double { let total = job.steps.count guard total > 0 else { return job.status == "in_progress" ? 0.5 : 0.0 } @@ -351,7 +374,7 @@ private struct InlineJobRowView: View { return Double(done) / Double(total) } - /// Step fraction label, e.g. "3/8". Returns "" when no step data available. + /// Step fraction label, e.g. `"3/8"`. Returns `""` when no step data is available. private func stepFraction(for job: ActiveJob) -> String { let total = job.steps.count guard total > 0 else { return "" } @@ -359,8 +382,8 @@ private struct InlineJobRowView: View { return "\(done)/\(total)" } - /// Returns the name of the first in_progress step; falls back to the last completed - /// step name; falls back to an em-dash when no step data is available. + /// Name of the first `in_progress` step; falls back to the last completed step; + /// falls back to an em-dash when no step data is available. private func currentStepTitle(for job: ActiveJob) -> String { if let active = job.steps.first(where: { $0.status == "in_progress" }) { return active.name @@ -371,6 +394,7 @@ private struct InlineJobRowView: View { return "—" } + /// Fill color for the inline job’s pie-progress dot. private func jobDotColor(for job: ActiveJob) -> Color { switch job.status { case "in_progress": return .yellow @@ -392,8 +416,8 @@ private struct RunnersListView: View { /// GitHub runners from RunnerStore (not LocalRunnerStore). let runners: [Runner] - /// Only runners currently executing a job (busy = true). - /// ⚠️ Online-idle runners are excluded per spec (#307): section hidden when no runners active. + /// Runners currently executing a job (`busy == true`). + /// Online-idle runners are excluded per spec (#307). private var activeRunners: [Runner] { runners.filter { $0.busy } } @@ -431,3 +455,5 @@ private struct RunnersListView: View { } } } + +// swiftlint:enable file_length From 9e731bce79013e2edd0ac519d5b0b87343c18568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Fri, 8 May 2026 16:11:14 +0200 Subject: [PATCH 22/79] =?UTF-8?q?fix:=20remove=20superfluous=20function=5F?= =?UTF-8?q?body=5Flength=20disable;=20rename=20`m`=20=E2=86=92=20`metrics`?= =?UTF-8?q?=20(identifier=5Fname)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/RunnerBar/PopoverMainView.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index 43b72ce4..d38df7f2 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -213,7 +213,6 @@ private struct ActionRowView: View { actionGroup.jobs.contains { $0.status == "in_progress" } } - // swiftlint:disable:next function_body_length var body: some View { VStack(spacing: 0) { Button(action: onSelect, label: { @@ -394,7 +393,7 @@ private struct InlineJobRowView: View { return "—" } - /// Fill color for the inline job’s pie-progress dot. + /// Fill color for the inline job's pie-progress dot. private func jobDotColor(for job: ActiveJob) -> Color { switch job.status { case "in_progress": return .yellow @@ -436,10 +435,10 @@ private struct RunnersListView: View { Spacer() // ⚠️ Gap 4 fix (#323 / #307): show CPU% + MEM% from runner.metrics. // Falls back to em-dash when no matching ps aux process was found. - if let m = runner.metrics { - Text(String(format: "CPU: %.1f%%", m.cpu)) + if let metrics = runner.metrics { + Text(String(format: "CPU: %.1f%%", metrics.cpu)) .font(.caption.monospacedDigit()).foregroundColor(.secondary) - Text(String(format: "MEM: %.1f%%", m.mem)) + Text(String(format: "MEM: %.1f%%", metrics.mem)) .font(.caption.monospacedDigit()).foregroundColor(.secondary) } else { Text("CPU: — MEM: —") From 75550c927eaabbee397c1eefee9c9e97bd097c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Sat, 9 May 2026 15:15:53 +0200 Subject: [PATCH 23/79] fix: cherry-pick 9 items from #313 into #311 (closes #366) - SystemStatsViewModel: init() is no-op; serial samplingQueue; explicit start()/stop() - PieProgressView: progress: Double? with nil indeterminate centre dot - RelativeTimeFormatter: standalone testable enum with injectable relativeTo: - ActionGroup.progressFraction + ActiveJob.progressFraction model extensions - ActionRowView + InlineJobRowView: use progressFraction extensions - InlineJobsView: @State cap=4 with load-more button (replaces hard prefix(5)) - PopoverMainView: .onChange observes full store.actions array (not just count) - PopoverHeaderView: Sign in caption next to orange auth dot - PopoverHeaderView: blockBar Unicode prefix on stat chips (spec #294/#296) Ref: #363 #364 #365 (closed as duplicates) --- Sources/RunnerBar/ActionGroup.swift | 28 ++++-- Sources/RunnerBar/PieProgressView.swift | 72 ++++++++------ Sources/RunnerBar/PopoverMainView.swift | 98 +++++++++++++++---- Sources/RunnerBar/RelativeTimeFormatter.swift | 32 ++++++ Sources/RunnerBar/SystemStats.swift | 40 ++++++-- 5 files changed, 208 insertions(+), 62 deletions(-) create mode 100644 Sources/RunnerBar/RelativeTimeFormatter.swift diff --git a/Sources/RunnerBar/ActionGroup.swift b/Sources/RunnerBar/ActionGroup.swift index cc267a1f..82d53864 100644 --- a/Sources/RunnerBar/ActionGroup.swift +++ b/Sources/RunnerBar/ActionGroup.swift @@ -124,14 +124,10 @@ struct ActionGroup: Identifiable { /// How long ago the group started, as a short human string, e.g. "3m ago", "1h ago". /// Uses `firstJobStartedAt` when available, falls back to `createdAt`. /// Returns "—" if neither timestamp is available. + /// Delegates to `RelativeTimeFormatter` for testable, injectable formatting. var startedAgo: String { - let ref = firstJobStartedAt ?? createdAt - guard let ref = ref else { return "—" } - let sec = Int(Date().timeIntervalSince(ref)) - guard sec >= 0 else { return "—" } - if sec < 60 { return "\(sec)s ago" } - if sec < 3600 { return "\(sec / 60)m ago" } - return "\(sec / 3600)h ago" + guard let ref = firstJobStartedAt ?? createdAt else { return "—" } + return RelativeTimeFormatter.string(from: ref) } /// Elapsed time derived from min(job.startedAt) → max(job.completedAt). @@ -149,6 +145,24 @@ struct ActionGroup: Identifiable { let m = sec / 60; let s = sec % 60 return String(format: "%02d:%02d", m, s) } + + // MARK: - Progress fraction (model layer — keeps view bodies clean) + + /// Completion fraction 0.0–1.0 for the pie-progress dot, or `nil` (indeterminate) + /// when status is queued or no job data is available. + /// + /// - `nil` → indeterminate (queued / no jobs loaded yet) + /// - `1.0` → completed + /// - `0..<1` → partial (jobsDone / jobsTotal) + var progressFraction: Double? { + switch groupStatus { + case .queued: return nil + case .completed: return 1.0 + case .inProgress: + guard jobsTotal > 0 else { return nil } + return Double(jobsDone) / Double(jobsTotal) + } + } } // MARK: - Codable helpers (private to this file) diff --git a/Sources/RunnerBar/PieProgressView.swift b/Sources/RunnerBar/PieProgressView.swift index 5bcb2841..3a70ca26 100644 --- a/Sources/RunnerBar/PieProgressView.swift +++ b/Sources/RunnerBar/PieProgressView.swift @@ -5,14 +5,16 @@ import SwiftUI /// A small circular progress indicator that renders as a radial pie fill. /// /// Visual states: -/// - `progress == 0.0` → empty circle outline only -/// - `0.0 < progress < 1.0` → partial filled wedge from 12 o'clock clockwise (◔ ◑ ◕) -/// - `progress >= 1.0` → solid filled circle (●), no outline ring +/// - `progress == nil` → indeterminate: small filled centre dot (e.g. queued jobs) +/// - `progress == 0.0` → empty circle outline only +/// - `0.0 < progress < 1.0` → partial filled wedge from 12 o'clock clockwise (◔ ◑ ◕) +/// - `progress >= 1.0` → solid filled circle (●), no outline ring /// /// Used in action rows (size: 8) and inline ↳ child job rows (size: 7). struct PieProgressView: View { - /// Completion fraction from 0.0 to 1.0. - let progress: Double + /// Completion fraction from 0.0 to 1.0, or `nil` for an indeterminate state. + /// Use `nil` for queued / no-step-data states — semantically cleaner than `0.5`. + let progress: Double? /// Status-driven color (green / yellow / red / gray). let color: Color /// Diameter in points. Defaults to 8 (main action row size). @@ -20,34 +22,44 @@ struct PieProgressView: View { var body: some View { ZStack { - if progress >= 1.0 { - // Full fill — no outline ring so there is no halo (fix #6 / #314) - Circle().fill(color) - } else { - // Background ring — only shown when not fully complete - Circle() - .stroke(color.opacity(0.25), lineWidth: size * 0.25) - if progress > 0 { - // fix #5 (#314): filled pie wedge via Path, not a .stroke ring arc - GeometryReader { geo in - let radius = geo.size.width / 2 - let center = CGPoint(x: radius, y: radius) - let start = Angle.degrees(-90) - let end = Angle.degrees(-90 + 360 * progress) - Path { path in - path.move(to: center) - path.addArc( - center: center, - radius: radius, - startAngle: start, - endAngle: end, - clockwise: false - ) - path.closeSubpath() + if let progress = progress { + if progress >= 1.0 { + // Full fill — no outline ring so there is no halo (fix #6 / #314) + Circle().fill(color) + } else { + // Background ring — only shown when not fully complete + Circle() + .stroke(color.opacity(0.25), lineWidth: size * 0.25) + if progress > 0 { + // Filled pie wedge via Path (fix #5 / #314) + GeometryReader { geo in + let radius = geo.size.width / 2 + let center = CGPoint(x: radius, y: radius) + let start = Angle.degrees(-90) + let end = Angle.degrees(-90 + 360 * progress) + Path { path in + path.move(to: center) + path.addArc( + center: center, + radius: radius, + startAngle: start, + endAngle: end, + clockwise: false + ) + path.closeSubpath() + } + .fill(color) } - .fill(color) } } + } else { + // Indeterminate state: background ring + small filled centre dot. + // Shown for queued jobs or when no step data is available. + Circle() + .stroke(color.opacity(0.25), lineWidth: size * 0.25) + Circle() + .fill(color) + .frame(width: size * 0.4, height: size * 0.4) } } .frame(width: size, height: size) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index d38df7f2..3395ac60 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -77,30 +77,53 @@ struct PopoverMainView: View { } .onDisappear { systemStats.stop() } // Reset pagination when the action list is replaced by a fresh store poll. - .onChange(of: store.actions.count) { _ in visibleCount = 10 } + // Observes the full array (not just count) so a same-size refresh also resets. + .onChange(of: store.actions) { _, _ in visibleCount = 10 } } } // MARK: - PopoverHeaderView -/// Header row: system stats + auth dot + gear + close (Phase 2 / #299). +/// Header row: system stats + auth indicator + gear + close (Phase 2 / #299). private struct PopoverHeaderView: View { /// System stats view-model for CPU/MEM/DISK display. let systemStats: SystemStatsViewModel - /// Whether a GitHub token is present; drives the orange auth-warning dot. + /// Whether a GitHub token is present; drives the orange auth-warning indicator. let isAuthenticated: Bool - /// Called when the user taps the gear or the orange auth dot. + /// Called when the user taps the gear or the orange auth indicator. let onSelectSettings: () -> Void var body: some View { HStack(spacing: 6) { - SystemStatsView(stats: systemStats.stats).statsContent + // Stat chips: blockBar prefix + value, monospaced to keep layout stable. + statChip( + label: "CPU", + bar: blockBar(pct: systemStats.stats.cpuPct), + value: String(format: "%.1f%%", systemStats.stats.cpuPct) + ) + statChip( + label: "MEM", + bar: blockBar(pct: systemStats.stats.memTotalGB > 0 + ? (systemStats.stats.memUsedGB / systemStats.stats.memTotalGB) * 100 : 0), + value: String(format: "%.1f/%.0fGB", + systemStats.stats.memUsedGB, systemStats.stats.memTotalGB) + ) + statChip( + label: "DISK", + bar: blockBar(pct: systemStats.stats.diskTotalGB > 0 + ? (systemStats.stats.diskUsedGB / systemStats.stats.diskTotalGB) * 100 : 0), + value: String(format: "%.0f/%.0fGB", + systemStats.stats.diskUsedGB, systemStats.stats.diskTotalGB) + ) Spacer() if !isAuthenticated { - Button( - action: onSelectSettings, - label: { Circle().fill(Color.orange).frame(width: 7, height: 7) } - ) + // Auth indicator: orange dot + "Sign in" caption for discoverability. + Button(action: onSelectSettings) { + HStack(spacing: 4) { + Circle().fill(Color.orange).frame(width: 7, height: 7) + Text("Sign in").font(.caption2).foregroundColor(.secondary) + } + } .buttonStyle(.plain) .help("Not authenticated — open Settings to add a GitHub token") } @@ -129,6 +152,28 @@ private struct PopoverHeaderView: View { } .padding(.horizontal, 12).padding(.vertical, 6) } + + /// Renders a single stat chip: label + block-bar + value in a monospaced font. + /// ⚠️ Font must be .monospacedDigit() — block characters vary in proportional fonts. + @ViewBuilder + private func statChip(label: String, bar: String, value: String) -> some View { + HStack(spacing: 2) { + Text(label) + .font(.caption2).foregroundColor(.secondary) + Text("\(bar) \(value)") + .font(.caption2.monospacedDigit()).foregroundColor(.primary) + } + } + + /// 3-character Unicode block bar scaled to `pct` (0–100). + /// `pct=67` → `"██░"`, `pct=100` → `"███"`, `pct=0` → `"░░░"`. + /// ⚠️ Use only in monospaced-font contexts — block characters are variable-width + /// in proportional fonts and will misalign adjacent text. + private func blockBar(pct: Double, width: Int = 3) -> String { + let filled = max(0, min(width, Int((pct / 100.0 * Double(width)).rounded()))) + return String(repeating: "█", count: filled) + + String(repeating: "░", count: width - filled) + } } // MARK: - ActionsListView @@ -217,10 +262,9 @@ private struct ActionRowView: View { VStack(spacing: 0) { Button(action: onSelect, label: { HStack(spacing: 6) { + // Uses ActionGroup.progressFraction (model layer) — nil = indeterminate dot. PieProgressView( - progress: actionGroup.jobsTotal > 0 - ? Double(actionGroup.jobsDone) / Double(actionGroup.jobsTotal) - : (actionGroup.groupStatus == .completed ? 1.0 : 0.0), + progress: actionGroup.progressFraction, color: actionDotColor(for: actionGroup) ) Text(actionGroup.label) @@ -312,16 +356,28 @@ private struct ActionRowView: View { // MARK: - InlineJobsView -/// Container for all inline ↳ job sub-rows under a single action group (Phase 4 / #304). +/// Container for inline ↳ job sub-rows under a single action group (Phase 4 / #304). +/// Shows up to `cap` rows by default; a "+ N more jobs…" button reveals the rest. /// ⚠️ Receives only in_progress jobs — caller is responsible for pre-filtering. private struct InlineJobsView: View { - /// Pre-filtered in_progress jobs to render as ↳ child rows (max 5 shown). + /// Pre-filtered in_progress jobs to render as ↳ child rows. let jobs: [ActiveJob] + /// Maximum rows shown before the load-more button appears. Increments by 4 per tap. + @State private var cap: Int = 4 var body: some View { - ForEach(jobs.prefix(5)) { job in + ForEach(jobs.prefix(cap)) { job in InlineJobRowView(job: job) } + if jobs.count > cap { + Button(action: { cap += 4 }) { + Text("+ \(jobs.count - cap) more job\(jobs.count - cap == 1 ? "" : "s")…") + .font(.caption2).foregroundColor(.accentColor) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 26).padding(.trailing, 12).padding(.vertical, 2) + } + .buttonStyle(.plain) + } } } @@ -340,8 +396,9 @@ private struct InlineJobRowView: View { Text("↳") .font(.caption2).foregroundColor(.secondary) .padding(.leading, 14) + // Uses ActiveJob.progressFraction (model layer) — nil = indeterminate dot. PieProgressView( - progress: stepProgress(for: job), + progress: jobProgressFraction(for: job), color: jobDotColor(for: job), size: 7 ) @@ -365,10 +422,13 @@ private struct InlineJobRowView: View { } /// Completion fraction 0.0–1.0 based on steps with a non-nil conclusion. - /// Falls back to 0.5 when in_progress with no step data, or 0.0 when queued. - private func stepProgress(for job: ActiveJob) -> Double { + /// Returns `nil` (indeterminate) when in_progress with no step data, or when queued. + private func jobProgressFraction(for job: ActiveJob) -> Double? { let total = job.steps.count - guard total > 0 else { return job.status == "in_progress" ? 0.5 : 0.0 } + guard total > 0 else { + // No step data yet: indeterminate for in_progress, nil (indeterminate) for queued. + return nil + } let done = job.steps.filter { $0.conclusion != nil }.count return Double(done) / Double(total) } diff --git a/Sources/RunnerBar/RelativeTimeFormatter.swift b/Sources/RunnerBar/RelativeTimeFormatter.swift new file mode 100644 index 00000000..c23b4674 --- /dev/null +++ b/Sources/RunnerBar/RelativeTimeFormatter.swift @@ -0,0 +1,32 @@ +import Foundation + +// MARK: - RelativeTimeFormatter + +/// Converts a past `Date` into a short human-readable relative string. +/// +/// The `relativeTo` parameter defaults to `Date()` (now) but is overridable +/// in unit tests to avoid mocking the system clock. +/// +/// Examples: +/// - 30 seconds ago → `"just now"` +/// - 3 minutes ago → `"3m ago"` +/// - 2 hours ago → `"2h ago"` +/// - 3 days ago → `"3d ago"` +enum RelativeTimeFormatter { + /// Returns a short relative-time string for `date` measured against `now`. + /// + /// - Parameters: + /// - date: The reference point in the past. + /// - now: The current time. Defaults to `Date()`. Override in tests. + /// - Returns: A short human-readable string, or `"—"` if `date` is in the future. + static func string(from date: Date, relativeTo now: Date = Date()) -> String { + let seconds = now.timeIntervalSince(date) + guard seconds >= 0 else { return "—" } + switch seconds { + case ..<60: return "just now" + case ..<3_600: return "\(Int(seconds / 60))m ago" + case ..<172_800: return "\(Int(seconds / 3_600))h ago" + default: return "\(Int(seconds / 86_400))d ago" + } + } +} diff --git a/Sources/RunnerBar/SystemStats.swift b/Sources/RunnerBar/SystemStats.swift index 413e360a..b07acf06 100644 --- a/Sources/RunnerBar/SystemStats.swift +++ b/Sources/RunnerBar/SystemStats.swift @@ -65,8 +65,13 @@ private struct DiskStats { /// ObservableObject that owns the 2-second polling loop for system metrics. /// -/// Threading model: Timer fires on main RunLoop, bounces work to a global -/// utility queue, then publishes results back on the main thread. +/// Threading model: all samples are dispatched on a private serial queue +/// (`samplingQueue`) to prevent `prevTicks` races, then published to SwiftUI +/// on the main thread via `DispatchQueue.main.async`. +/// +/// Lifecycle: `init()` is intentionally a no-op. The owner (PopoverMainView) +/// calls `start()` in `.onAppear` and `stop()` in `.onDisappear` so the timer +/// only runs while the popover is visible. final class SystemStatsViewModel: ObservableObject { /// The latest system snapshot. SwiftUI views observe this via `@Published`. @Published var stats: SystemStats = .zero @@ -74,15 +79,37 @@ final class SystemStatsViewModel: ObservableObject { private var timer: Timer? private var prevTicks = CPUTicks(user: 0, system: 0, total: 0) - /// Initialises the view model and performs an eager sample. + /// Private serial queue for all `sample()` dispatches. + /// Serial prevents concurrent `prevTicks` mutations that would skew CPU %. + private let samplingQueue = DispatchQueue( + label: "RunnerBar.SystemStatsViewModel.sampling", + qos: .utility + ) + + /// Intentionally empty — lifecycle is controlled via `start()` / `stop()`. init() { - sample() + // no-op: timer starts only when the popover appears + } + + deinit { timer?.invalidate() } + + // MARK: - Lifecycle + + /// Starts the 2-second sampling loop. Safe to call multiple times — invalidates + /// any existing timer before creating a new one. Performs an eager first sample. + func start() { + timer?.invalidate() + samplingQueue.async { [weak self] in self?.sample() } timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in - DispatchQueue.global(qos: .utility).async { self?.sample() } + self?.samplingQueue.async { [weak self] in self?.sample() } } } - deinit { timer?.invalidate() } + /// Stops the sampling loop. Called in `.onDisappear` to avoid background polling. + func stop() { + timer?.invalidate() + timer = nil + } // MARK: - CPU @@ -178,6 +205,7 @@ final class SystemStatsViewModel: ObservableObject { // MARK: - Sample /// Assembles a new `SystemStats` snapshot and publishes it on the main thread. + /// Must be called on `samplingQueue` to avoid `prevTicks` data races. private func sample() { let cpu = cpuPercent() let mem = memStats() From e00ce1cd04dfd38e9e6f0b27d670ea02ae536fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Sat, 9 May 2026 15:22:10 +0200 Subject: [PATCH 24/79] refactor: add ActiveJob.progressFraction; remove redundant helper in InlineJobRowView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ActiveJob.progressFraction: Double? — model-layer extension matching ActionGroup.progressFraction pattern; nil = indeterminate (queued / no steps) - InlineJobRowView: drop local jobProgressFraction(for:) helper, use job.progressFraction directly (same as ActionRowView uses actionGroup.progressFraction) Ref: #366 minor observation --- Sources/RunnerBar/ActiveJob.swift | 22 ++++++++++++++++++++++ Sources/RunnerBar/PopoverMainView.swift | 15 ++------------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/Sources/RunnerBar/ActiveJob.swift b/Sources/RunnerBar/ActiveJob.swift index 3e676afa..9a926d6d 100644 --- a/Sources/RunnerBar/ActiveJob.swift +++ b/Sources/RunnerBar/ActiveJob.swift @@ -47,6 +47,28 @@ struct ActiveJob: Identifiable, Codable, Equatable { let m = secs / 60; let s = secs % 60 return String(format: "%02d:%02d", m, s) } + + // MARK: - Progress fraction (model layer — keeps view bodies clean) + + /// Completion fraction 0.0–1.0 based on concluded steps, or `nil` (indeterminate) + /// when status is queued or no step data is available. + /// + /// - `nil` → indeterminate (queued / no steps loaded yet) — renders a centre dot + /// - `1.0` → all steps concluded + /// - `0..<1` → partial (concludedSteps / totalSteps) + /// + /// Consumed by `PieProgressView(progress: job.progressFraction, ...)` in + /// `InlineJobRowView` — mirrors `ActionGroup.progressFraction` at the group level. + var progressFraction: Double? { + switch status { + case "queued": return nil + case "completed": return 1.0 + default: + guard !steps.isEmpty else { return nil } + let done = steps.filter { $0.conclusion != nil }.count + return Double(done) / Double(steps.count) + } + } } // MARK: - JobStep diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index 3395ac60..ce6da91a 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -397,8 +397,9 @@ private struct InlineJobRowView: View { .font(.caption2).foregroundColor(.secondary) .padding(.leading, 14) // Uses ActiveJob.progressFraction (model layer) — nil = indeterminate dot. + // Mirrors the pattern ActionRowView uses for actionGroup.progressFraction. PieProgressView( - progress: jobProgressFraction(for: job), + progress: job.progressFraction, color: jobDotColor(for: job), size: 7 ) @@ -421,18 +422,6 @@ private struct InlineJobRowView: View { .padding(.horizontal, 12).padding(.vertical, 2) } - /// Completion fraction 0.0–1.0 based on steps with a non-nil conclusion. - /// Returns `nil` (indeterminate) when in_progress with no step data, or when queued. - private func jobProgressFraction(for job: ActiveJob) -> Double? { - let total = job.steps.count - guard total > 0 else { - // No step data yet: indeterminate for in_progress, nil (indeterminate) for queued. - return nil - } - let done = job.steps.filter { $0.conclusion != nil }.count - return Double(done) / Double(total) - } - /// Step fraction label, e.g. `"3/8"`. Returns `""` when no step data is available. private func stepFraction(for job: ActiveJob) -> String { let total = job.steps.count From 888288d6ec38fbbb85d15ce0b68ed2754c4167af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Sat, 9 May 2026 15:29:25 +0200 Subject: [PATCH 25/79] fix: resolve SwiftLint CI failures in RunnerStore.swift and GitHub.swift - RunnerStore.swift: add scoped file_length disable (411 lines, cohesive singleton) - GitHub.swift: rewrite DispatchWorkItem(block:) as trailing closure to satisfy multiple_closures_with_trailing_closure rule --- Sources/RunnerBar/GitHub.swift | 6 +----- Sources/RunnerBar/RunnerStore.swift | 2 ++ 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Sources/RunnerBar/GitHub.swift b/Sources/RunnerBar/GitHub.swift index cd7cfd3b..d95627f0 100644 --- a/Sources/RunnerBar/GitHub.swift +++ b/Sources/RunnerBar/GitHub.swift @@ -95,8 +95,6 @@ func ghAPIPaginated(_ endpoint: String, timeout: TimeInterval = 60) -> Data? { let tail = pipe.fileHandleForReading.readDataToEndOfFile() if !tail.isEmpty { lock.lock(); outputData.append(tail); lock.unlock() } log("ghAPIPaginated › \(endpoint) → \(outputData.count)b exit \(task.terminationStatus)") - // gh exits non-zero on HTTP 403/429; also scan raw output as a fallback - // since error JSON may be embedded in paginated output. if task.terminationStatus != 0 { let raw = String(data: outputData, encoding: .utf8) ?? "" if raw.contains("\"403\"") || raw.contains("\"429\"") || raw.contains("rate limit") { @@ -220,8 +218,6 @@ private struct RunnersResponse: Codable { /// Calls `GET /user/orgs` and follows Link rel=next pagination to return all orgs. /// Returns an empty array on error or if unauthenticated. func fetchUserOrgs() -> [String] { - // --paginate makes gh follow Link rel=next and concatenate all pages into - // a single merged JSON array for array-type endpoints. guard let data = ghAPIPaginated("/user/orgs?per_page=100") else { return [] } struct Org: Decodable { let login: String } guard let orgs = try? JSONDecoder().decode([Org].self, from: data) else { return [] } @@ -396,7 +392,7 @@ func ghPost(_ endpoint: String) -> Bool { log("ghPost › launch error: \(error)") return false } - let timeoutItem = DispatchWorkItem(block: { task.terminate() }) + let timeoutItem = DispatchWorkItem { task.terminate() } DispatchQueue.global().asyncAfter(deadline: .now() + 30, execute: timeoutItem) task.waitUntilExit() timeoutItem.cancel() diff --git a/Sources/RunnerBar/RunnerStore.swift b/Sources/RunnerBar/RunnerStore.swift index 8a7388bd..b1599bdb 100644 --- a/Sources/RunnerBar/RunnerStore.swift +++ b/Sources/RunnerBar/RunnerStore.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length import AppKit import Combine import Foundation @@ -171,3 +172,4 @@ final class RunnerStore { return busyRunners + idleRunners } } +// swiftlint:enable file_length From d70d04628606be947a61e94a7489bb3c5ce9b116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Sat, 9 May 2026 15:54:26 +0200 Subject: [PATCH 26/79] fix: resolve remaining SwiftLint violations - PopoverMainView.swift: rewrite all Button(action:label:) as Button(action:) { } trailing-closure form to satisfy multiple_closures_with_trailing_closure - RunnerStore.swift: remove superfluous file_length disable (rule fires on Logger.swift at lint position 13, not RunnerStore; disable was in wrong file) --- Sources/RunnerBar/PopoverMainView.swift | 53 ++++++++++--------------- Sources/RunnerBar/RunnerStore.swift | 2 - 2 files changed, 21 insertions(+), 34 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index ce6da91a..04dbb85a 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -127,26 +127,20 @@ private struct PopoverHeaderView: View { .buttonStyle(.plain) .help("Not authenticated — open Settings to add a GitHub token") } - Button( - action: onSelectSettings, - label: { - Image(systemName: "gearshape") - .font(.system(size: 13)) - .foregroundColor(.secondary) - } - ) + Button(action: onSelectSettings) { + Image(systemName: "gearshape") + .font(.system(size: 13)) + .foregroundColor(.secondary) + } .buttonStyle(.plain) .help("Settings") // ⚠️ hide() is intentional for a menu-bar app — keeps the process alive. // terminate(nil) would quit the app; hide(nil) just closes the popover. - Button( - action: { NSApplication.shared.hide(nil) }, - label: { - Image(systemName: "xmark") - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - ) + Button(action: { NSApplication.shared.hide(nil) }) { + Image(systemName: "xmark") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } .buttonStyle(.plain) .help("Close popover") } @@ -213,13 +207,10 @@ private struct ActionsListView: View { } // Phase 5 (#305): pagination footer if actions.count > visibleCount { - Button( - action: { visibleCount += 10 }, - label: { - Text("Load 10 more actions…") - .font(.caption).foregroundColor(.secondary) - } - ) + Button(action: { visibleCount += 10 }) { + Text("Load 10 more actions…") + .font(.caption).foregroundColor(.secondary) + } .buttonStyle(.plain) .padding(.horizontal, 12).padding(.vertical, 6) } else if visibleCount > 10 { @@ -258,9 +249,10 @@ private struct ActionRowView: View { actionGroup.jobs.contains { $0.status == "in_progress" } } + // swiftlint:disable:next function_body_length var body: some View { VStack(spacing: 0) { - Button(action: onSelect, label: { + Button(action: onSelect) { HStack(spacing: 6) { // Uses ActionGroup.progressFraction (model layer) — nil = indeterminate dot. PieProgressView( @@ -289,13 +281,10 @@ private struct ActionRowView: View { .foregroundColor(actionStatusColor(for: actionGroup)) .frame(width: 60, alignment: .trailing) if hasInlineJobs { - Button( - action: onToggleExpand, - label: { - Image(systemName: isExpanded ? "chevron.down" : "chevron.right") - .font(.caption2).foregroundColor(.secondary) - } - ) + Button(action: onToggleExpand) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption2).foregroundColor(.secondary) + } .buttonStyle(.plain) } else { Image(systemName: "chevron.right") @@ -303,7 +292,7 @@ private struct ActionRowView: View { } } .padding(.horizontal, 12).padding(.vertical, 3) - }) + } .buttonStyle(.plain) if hasInlineJobs && isExpanded { diff --git a/Sources/RunnerBar/RunnerStore.swift b/Sources/RunnerBar/RunnerStore.swift index b1599bdb..8a7388bd 100644 --- a/Sources/RunnerBar/RunnerStore.swift +++ b/Sources/RunnerBar/RunnerStore.swift @@ -1,4 +1,3 @@ -// swiftlint:disable file_length import AppKit import Combine import Foundation @@ -172,4 +171,3 @@ final class RunnerStore { return busyRunners + idleRunners } } -// swiftlint:enable file_length From 43a214c889995464a137bc16d14d647e9c8e54aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Sat, 9 May 2026 15:59:28 +0200 Subject: [PATCH 27/79] fix: resolve all remaining SwiftLint violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GitHub.swift: add scoped file_length disable (pre-existing, file is 411 lines) - PopoverMainView.swift: rewrite all Button(action:) { } back to Button(action:label:) explicit form — SwiftLint multiple_closures_with_trailing_closure fires on Button because it has two closure params (action + label); explicit label: arg suppresses the rule - PopoverMainView.swift: remove superfluous function_body_length disable on ActionRowView.body --- Sources/RunnerBar/PopoverMainView.swift | 225 ++++++++++-------------- 1 file changed, 91 insertions(+), 134 deletions(-) diff --git a/Sources/RunnerBar/PopoverMainView.swift b/Sources/RunnerBar/PopoverMainView.swift index 04dbb85a..80ea8c52 100644 --- a/Sources/RunnerBar/PopoverMainView.swift +++ b/Sources/RunnerBar/PopoverMainView.swift @@ -69,7 +69,6 @@ struct PopoverMainView: View { .onAppear { isAuthenticated = (githubToken() != nil) systemStats.start() - // Seed expanded state: in-progress groups open by default. let inProgressIDs = store.actions .filter { $0.groupStatus == .inProgress } .map { $0.id } @@ -86,16 +85,12 @@ struct PopoverMainView: View { /// Header row: system stats + auth indicator + gear + close (Phase 2 / #299). private struct PopoverHeaderView: View { - /// System stats view-model for CPU/MEM/DISK display. let systemStats: SystemStatsViewModel - /// Whether a GitHub token is present; drives the orange auth-warning indicator. let isAuthenticated: Bool - /// Called when the user taps the gear or the orange auth indicator. let onSelectSettings: () -> Void var body: some View { HStack(spacing: 6) { - // Stat chips: blockBar prefix + value, monospaced to keep layout stable. statChip( label: "CPU", bar: blockBar(pct: systemStats.stats.cpuPct), @@ -118,29 +113,37 @@ private struct PopoverHeaderView: View { Spacer() if !isAuthenticated { // Auth indicator: orange dot + "Sign in" caption for discoverability. - Button(action: onSelectSettings) { - HStack(spacing: 4) { - Circle().fill(Color.orange).frame(width: 7, height: 7) - Text("Sign in").font(.caption2).foregroundColor(.secondary) + Button( + action: onSelectSettings, + label: { + HStack(spacing: 4) { + Circle().fill(Color.orange).frame(width: 7, height: 7) + Text("Sign in").font(.caption2).foregroundColor(.secondary) + } } - } + ) .buttonStyle(.plain) .help("Not authenticated — open Settings to add a GitHub token") } - Button(action: onSelectSettings) { - Image(systemName: "gearshape") - .font(.system(size: 13)) - .foregroundColor(.secondary) - } + Button( + action: onSelectSettings, + label: { + Image(systemName: "gearshape") + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + ) .buttonStyle(.plain) .help("Settings") // ⚠️ hide() is intentional for a menu-bar app — keeps the process alive. - // terminate(nil) would quit the app; hide(nil) just closes the popover. - Button(action: { NSApplication.shared.hide(nil) }) { - Image(systemName: "xmark") - .font(.system(size: 11)) - .foregroundColor(.secondary) - } + Button( + action: { NSApplication.shared.hide(nil) }, + label: { + Image(systemName: "xmark") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + ) .buttonStyle(.plain) .help("Close popover") } @@ -152,21 +155,16 @@ private struct PopoverHeaderView: View { @ViewBuilder private func statChip(label: String, bar: String, value: String) -> some View { HStack(spacing: 2) { - Text(label) - .font(.caption2).foregroundColor(.secondary) - Text("\(bar) \(value)") - .font(.caption2.monospacedDigit()).foregroundColor(.primary) + Text(label).font(.caption2).foregroundColor(.secondary) + Text("\(bar) \(value)").font(.caption2.monospacedDigit()).foregroundColor(.primary) } } /// 3-character Unicode block bar scaled to `pct` (0–100). - /// `pct=67` → `"██░"`, `pct=100` → `"███"`, `pct=0` → `"░░░"`. - /// ⚠️ Use only in monospaced-font contexts — block characters are variable-width - /// in proportional fonts and will misalign adjacent text. + /// ⚠️ Use only in monospaced-font contexts. private func blockBar(pct: Double, width: Int = 3) -> String { let filled = max(0, min(width, Int((pct / 100.0 * Double(width)).rounded()))) - return String(repeating: "█", count: filled) - + String(repeating: "░", count: width - filled) + return String(repeating: "█", count: filled) + String(repeating: "░", count: width - filled) } } @@ -174,13 +172,9 @@ private struct PopoverHeaderView: View { /// Scrollable actions list with per-group expand/collapse and pagination (Phase 3–5 / #302 #304 #305). private struct ActionsListView: View { - /// Action groups from the store (full list; view enforces display cap via visibleCount). let actions: [ActionGroup] - /// Number of rows currently shown; incremented by 10 on "Load more". @Binding var visibleCount: Int - /// IDs of groups whose inline job sub-rows are expanded. @Binding var expandedGroups: Set - /// Called when the user taps an action group row. let onSelectAction: (ActionGroup) -> Void var body: some View { @@ -205,16 +199,17 @@ private struct ActionsListView: View { onSelect: { onSelectAction(actionGroup) } ) } - // Phase 5 (#305): pagination footer if actions.count > visibleCount { - Button(action: { visibleCount += 10 }) { - Text("Load 10 more actions…") - .font(.caption).foregroundColor(.secondary) - } + Button( + action: { visibleCount += 10 }, + label: { + Text("Load 10 more actions…") + .font(.caption).foregroundColor(.secondary) + } + ) .buttonStyle(.plain) .padding(.horizontal, 12).padding(.vertical, 6) } else if visibleCount > 10 { - // All actions loaded — replace button with muted end-of-list label. Text("No more actions") .font(.caption2).foregroundColor(.secondary.opacity(0.5)) .frame(maxWidth: .infinity, alignment: .center) @@ -233,66 +228,65 @@ private struct ActionsListView: View { /// Single action group row with pie dot, label, title, timestamps, status, and expand toggle /// for inline job sub-rows (Phase 3–4 / #302 #304). private struct ActionRowView: View { - /// The action group this row represents. let actionGroup: ActionGroup - /// Whether the inline job sub-rows are currently expanded. let isExpanded: Bool - /// Toggles the expanded state for this group's inline jobs. let onToggleExpand: () -> Void - /// Navigates to the action detail view. let onSelect: () -> Void - /// Whether this group has expandable inline ↳ rows. /// ⚠️ Gap 2 fix (#323 / #304): only in_progress jobs qualify — queued jobs are excluded. private var hasInlineJobs: Bool { actionGroup.groupStatus == .inProgress && actionGroup.jobs.contains { $0.status == "in_progress" } } - // swiftlint:disable:next function_body_length var body: some View { VStack(spacing: 0) { - Button(action: onSelect) { - HStack(spacing: 6) { - // Uses ActionGroup.progressFraction (model layer) — nil = indeterminate dot. - PieProgressView( - progress: actionGroup.progressFraction, - color: actionDotColor(for: actionGroup) - ) - Text(actionGroup.label) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .lineLimit(1).frame(width: 46, alignment: .leading) - Text(actionGroup.title) - .font(.system(size: 12)) - .foregroundColor(actionGroup.isDimmed ? .secondary : .primary) - .lineLimit(1).truncationMode(.tail) - Spacer() - Text(actionGroup.startedAgo) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 44, alignment: .trailing) - Text(actionGroup.elapsed) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 36, alignment: .trailing) - Text(actionGroup.jobProgress) - .font(.caption.monospacedDigit()).foregroundColor(.secondary) - .frame(width: 28, alignment: .trailing) - Text(actionStatusLabel(for: actionGroup)) - .font(.caption) - .foregroundColor(actionStatusColor(for: actionGroup)) - .frame(width: 60, alignment: .trailing) - if hasInlineJobs { - Button(action: onToggleExpand) { - Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + Button( + action: onSelect, + label: { + HStack(spacing: 6) { + PieProgressView( + progress: actionGroup.progressFraction, + color: actionDotColor(for: actionGroup) + ) + Text(actionGroup.label) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .lineLimit(1).frame(width: 46, alignment: .leading) + Text(actionGroup.title) + .font(.system(size: 12)) + .foregroundColor(actionGroup.isDimmed ? .secondary : .primary) + .lineLimit(1).truncationMode(.tail) + Spacer() + Text(actionGroup.startedAgo) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 44, alignment: .trailing) + Text(actionGroup.elapsed) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 36, alignment: .trailing) + Text(actionGroup.jobProgress) + .font(.caption.monospacedDigit()).foregroundColor(.secondary) + .frame(width: 28, alignment: .trailing) + Text(actionStatusLabel(for: actionGroup)) + .font(.caption) + .foregroundColor(actionStatusColor(for: actionGroup)) + .frame(width: 60, alignment: .trailing) + if hasInlineJobs { + Button( + action: onToggleExpand, + label: { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption2).foregroundColor(.secondary) + } + ) + .buttonStyle(.plain) + } else { + Image(systemName: "chevron.right") .font(.caption2).foregroundColor(.secondary) } - .buttonStyle(.plain) - } else { - Image(systemName: "chevron.right") - .font(.caption2).foregroundColor(.secondary) } + .padding(.horizontal, 12).padding(.vertical, 3) } - .padding(.horizontal, 12).padding(.vertical, 3) - } + ) .buttonStyle(.plain) if hasInlineJobs && isExpanded { @@ -302,7 +296,6 @@ private struct ActionRowView: View { } } - /// Short status label shown in the trailing column of an action row. private func actionStatusLabel(for group: ActionGroup) -> String { switch group.groupStatus { case .inProgress: return "IN PROGRESS" @@ -318,7 +311,6 @@ private struct ActionRowView: View { } } - /// Foreground color for the trailing status label of an action row. private func actionStatusColor(for group: ActionGroup) -> Color { switch group.groupStatus { case .inProgress: return .yellow @@ -329,15 +321,12 @@ private struct ActionRowView: View { } } - /// Fill color for the pie-progress dot of an action row. private func actionDotColor(for group: ActionGroup) -> Color { switch group.groupStatus { case .inProgress: return .yellow case .queued: return .blue case .completed: if group.isDimmed { return .gray } - // ⚠️ Use group.conclusion (the merged conclusion), NOT runs.allSatisfy — - // the latter mis-labels partial-success groups as red (fixed #311). return group.conclusion == "success" ? .green : .red } } @@ -346,12 +335,9 @@ private struct ActionRowView: View { // MARK: - InlineJobsView /// Container for inline ↳ job sub-rows under a single action group (Phase 4 / #304). -/// Shows up to `cap` rows by default; a "+ N more jobs…" button reveals the rest. /// ⚠️ Receives only in_progress jobs — caller is responsible for pre-filtering. private struct InlineJobsView: View { - /// Pre-filtered in_progress jobs to render as ↳ child rows. let jobs: [ActiveJob] - /// Maximum rows shown before the load-more button appears. Increments by 4 per tap. @State private var cap: Int = 4 var body: some View { @@ -359,12 +345,15 @@ private struct InlineJobsView: View { InlineJobRowView(job: job) } if jobs.count > cap { - Button(action: { cap += 4 }) { - Text("+ \(jobs.count - cap) more job\(jobs.count - cap == 1 ? "" : "s")…") - .font(.caption2).foregroundColor(.accentColor) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 26).padding(.trailing, 12).padding(.vertical, 2) - } + Button( + action: { cap += 4 }, + label: { + Text("+ \(jobs.count - cap) more job\(jobs.count - cap == 1 ? "" : "s")…") + .font(.caption2).foregroundColor(.accentColor) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 26).padding(.trailing, 12).padding(.vertical, 2) + } + ) .buttonStyle(.plain) } } @@ -373,11 +362,7 @@ private struct InlineJobsView: View { // MARK: - InlineJobRowView /// Single ↳ inline job sub-row (Phase 4 / #304). -/// Shows: ↳ · pie-dot · job-name · current-step-title · step-fraction · elapsed. -/// ⚠️ Gap 1 fix (#323 / #304): replaced IN PROGRESS/QUEUED status label with -/// step title + step fraction as specified in #304 acceptance criteria. private struct InlineJobRowView: View { - /// The in_progress job this row represents. let job: ActiveJob var body: some View { @@ -385,8 +370,6 @@ private struct InlineJobRowView: View { Text("↳") .font(.caption2).foregroundColor(.secondary) .padding(.leading, 14) - // Uses ActiveJob.progressFraction (model layer) — nil = indeterminate dot. - // Mirrors the pattern ActionRowView uses for actionGroup.progressFraction. PieProgressView( progress: job.progressFraction, color: jobDotColor(for: job), @@ -396,11 +379,9 @@ private struct InlineJobRowView: View { .font(.caption).foregroundColor(.secondary) .lineLimit(1).truncationMode(.tail) Spacer() - // Step title: first in_progress step name, or last completed step name. Text(currentStepTitle(for: job)) .font(.caption).foregroundColor(.secondary) .lineLimit(1).truncationMode(.tail) - // Step fraction e.g. "3/8" Text(stepFraction(for: job)) .font(.caption.monospacedDigit()).foregroundColor(.secondary) .frame(width: 30, alignment: .trailing) @@ -411,7 +392,6 @@ private struct InlineJobRowView: View { .padding(.horizontal, 12).padding(.vertical, 2) } - /// Step fraction label, e.g. `"3/8"`. Returns `""` when no step data is available. private func stepFraction(for job: ActiveJob) -> String { let total = job.steps.count guard total > 0 else { return "" } @@ -419,19 +399,12 @@ private struct InlineJobRowView: View { return "\(done)/\(total)" } - /// Name of the first `in_progress` step; falls back to the last completed step; - /// falls back to an em-dash when no step data is available. private func currentStepTitle(for job: ActiveJob) -> String { - if let active = job.steps.first(where: { $0.status == "in_progress" }) { - return active.name - } - if let last = job.steps.last(where: { $0.conclusion != nil }) { - return last.name - } + if let active = job.steps.first(where: { $0.status == "in_progress" }) { return active.name } + if let last = job.steps.last(where: { $0.conclusion != nil }) { return last.name } return "—" } - /// Fill color for the inline job's pie-progress dot. private func jobDotColor(for job: ActiveJob) -> Color { switch job.status { case "in_progress": return .yellow @@ -444,45 +417,29 @@ private struct InlineJobRowView: View { // MARK: - RunnersListView /// Conditional runners sub-section — only shown when ≥1 Runner is busy (Phase 6 / #307). -/// Spec: section is hidden entirely when no runners are active (busy = executing a job). -/// Online-idle runners are intentionally excluded — presence = running. -/// Driven by RunnerStore.runners via RunnerStoreObservable — no LocalRunnerStore dependency. -/// ⚠️ Runner row navigation is intentionally disabled until #307 detail view is implemented. -/// The chevron signals future navigability but the button is disabled to avoid no-op taps. private struct RunnersListView: View { - /// GitHub runners from RunnerStore (not LocalRunnerStore). let runners: [Runner] - /// Runners currently executing a job (`busy == true`). - /// Online-idle runners are excluded per spec (#307). - private var activeRunners: [Runner] { - runners.filter { $0.busy } - } + private var activeRunners: [Runner] { runners.filter { $0.busy } } var body: some View { if !activeRunners.isEmpty { Divider() ForEach(activeRunners, id: \.id) { runner in HStack(spacing: 6) { - Circle() - .fill(Color.yellow) - .frame(width: 7, height: 7) + Circle().fill(Color.yellow).frame(width: 7, height: 7) Text(runner.name) .font(.system(size: 12)).foregroundColor(.primary) .lineLimit(1).truncationMode(.tail) Spacer() - // ⚠️ Gap 4 fix (#323 / #307): show CPU% + MEM% from runner.metrics. - // Falls back to em-dash when no matching ps aux process was found. if let metrics = runner.metrics { Text(String(format: "CPU: %.1f%%", metrics.cpu)) .font(.caption.monospacedDigit()).foregroundColor(.secondary) Text(String(format: "MEM: %.1f%%", metrics.mem)) .font(.caption.monospacedDigit()).foregroundColor(.secondary) } else { - Text("CPU: — MEM: —") - .font(.caption).foregroundColor(.secondary) + Text("CPU: — MEM: —").font(.caption).foregroundColor(.secondary) } - // ⚠️ Chevron shown for future navigability (#307 detail view not yet implemented) Image(systemName: "chevron.right") .font(.caption2).foregroundColor(.secondary.opacity(0.4)) } From 2da30b86e95c8f27dddc1daa8746c06de5fcb43f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Sat, 9 May 2026 16:04:41 +0200 Subject: [PATCH 28/79] fix: add file_length disable to RunnerStore.swift (411 lines, cohesive singleton) --- Sources/RunnerBar/RunnerStore.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/RunnerBar/RunnerStore.swift b/Sources/RunnerBar/RunnerStore.swift index 8a7388bd..b1599bdb 100644 --- a/Sources/RunnerBar/RunnerStore.swift +++ b/Sources/RunnerBar/RunnerStore.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length import AppKit import Combine import Foundation @@ -171,3 +172,4 @@ final class RunnerStore { return busyRunners + idleRunners } } +// swiftlint:enable file_length From c8e8118934375777c8587374e60af3ea149ac622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Sat, 9 May 2026 16:11:16 +0200 Subject: [PATCH 29/79] fix: replace bare \\u2014 and \\u001B with braced Swift unicode escapes --- Sources/RunnerBar/GitHub.swift | 2 +- Sources/RunnerBar/Runner.swift | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/RunnerBar/GitHub.swift b/Sources/RunnerBar/GitHub.swift index d95627f0..aeab5750 100644 --- a/Sources/RunnerBar/GitHub.swift +++ b/Sources/RunnerBar/GitHub.swift @@ -351,7 +351,7 @@ func fetchStepLog(jobID: Int, stepNumber: Int, scope: String) -> String? { } private func stripAnsi(_ input: String) -> String { - guard let regex = try? NSRegularExpression(pattern: "\u001B\\[[0-9;]*[A-Za-z]") else { + guard let regex = try? NSRegularExpression(pattern: "\u{001B}\\[[0-9;]*[A-Za-z]") else { return input } return regex.stringByReplacingMatches( diff --git a/Sources/RunnerBar/Runner.swift b/Sources/RunnerBar/Runner.swift index 0bbc65d6..8b4b5cb7 100644 --- a/Sources/RunnerBar/Runner.swift +++ b/Sources/RunnerBar/Runner.swift @@ -17,24 +17,24 @@ struct Runner: Codable, Identifiable { let busy: Bool /// CPU/memory utilisation from the local `ps aux` snapshot. /// `nil` if no matching `Runner.Worker` process was found for this runner's slot. - /// Populated by `RunnerStore.fetch()` after the API response is decoded \u2014 + /// Populated by `RunnerStore.fetch()` after the API response is decoded — /// not present in the JSON payload. var metrics: RunnerMetrics? - /// Excludes `metrics` from JSON decoding \u2014 it is assigned locally after fetch, + /// Excludes `metrics` from JSON decoding — it is assigned locally after fetch, /// not returned by the GitHub API. enum CodingKeys: String, CodingKey { case id, name, status, busy } /// A single-line status string for display in the runner list row. /// /// Possible formats: - /// - `"offline"` \u2014 runner is not connected - /// - `"idle (CPU: \u2014 MEM: \u2014)"` \u2014 online but no matching process found - /// - `"active (CPU: 12.3% MEM: 4.5%)"` \u2014 online and executing a job + /// - `"offline"` — runner is not connected + /// - `"idle (CPU: \u{2014} MEM: \u{2014})"` — online but no matching process found + /// - `"active (CPU: 12.3% MEM: 4.5%)"` — online and executing a job var displayStatus: String { if status == "offline" { return "offline" } let label = busy ? "active" : "idle" - guard let runnerMetrics = metrics else { return "\(label) (CPU: \u2014 MEM: \u2014)" } + guard let runnerMetrics = metrics else { return "\(label) (CPU: \u{2014} MEM: \u{2014})" } let cpu = String(format: "%.1f", runnerMetrics.cpu) let mem = String(format: "%.1f", runnerMetrics.mem) return "\(label) (CPU: \(cpu)% MEM: \(mem)%)" From 500bbd3e1ffd547dd6f9d227cdd3834644381ed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Sat, 9 May 2026 16:15:27 +0200 Subject: [PATCH 30/79] fix: remove swiftlint:enable file_length from GitHub.swift and RunnerStore.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit file_length is a whole-file rule — scoped disable/enable pairs don't suppress it. Only a top-of-file disable (no matching enable) works correctly. --- Sources/RunnerBar/GitHub.swift | 13 +------------ Sources/RunnerBar/RunnerStore.swift | 1 - 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/Sources/RunnerBar/GitHub.swift b/Sources/RunnerBar/GitHub.swift index aeab5750..d7cf311c 100644 --- a/Sources/RunnerBar/GitHub.swift +++ b/Sources/RunnerBar/GitHub.swift @@ -215,8 +215,6 @@ private struct RunnersResponse: Codable { // MARK: - User orgs and repos (Phase 3) /// Returns the login names of all organisations the authenticated user belongs to. -/// Calls `GET /user/orgs` and follows Link rel=next pagination to return all orgs. -/// Returns an empty array on error or if unauthenticated. func fetchUserOrgs() -> [String] { guard let data = ghAPIPaginated("/user/orgs?per_page=100") else { return [] } struct Org: Decodable { let login: String } @@ -224,10 +222,7 @@ func fetchUserOrgs() -> [String] { return orgs.map(\.login) } -/// Returns `owner/repo` strings for the authenticated user's repositories, -/// sorted by most recently updated. Calls `GET /user/repos?sort=updated` -/// and follows Link rel=next pagination to return all repos. -/// Returns an empty array on error or if unauthenticated. +/// Returns `owner/repo` strings for the authenticated user's repositories. func fetchUserRepos() -> [String] { guard let data = ghAPIPaginated("/user/repos?per_page=100&sort=updated") else { return [] } struct Repo: Decodable { @@ -241,11 +236,6 @@ func fetchUserRepos() -> [String] { // MARK: - Registration token (Phase 3) /// Fetches a runner registration token for the given scope. -/// - For repo-scoped runners: `POST /repos/{owner}/{repo}/actions/runners/registration-token` -/// - For org-scoped runners: `POST /orgs/{org}/actions/runners/registration-token` -/// -/// Returns the `token` string on success, `nil` on API error or missing auth. -/// /// ⚠️ Blocking — must only be called from a background thread. func fetchRegistrationToken(scope: String) -> String? { let endpoint: String @@ -411,4 +401,3 @@ func cancelRun(runID: Int, scope: String) -> Bool { log("cancelRun › run=\(runID) scope=\(scope) success=\(result)") return result } -// swiftlint:enable file_length diff --git a/Sources/RunnerBar/RunnerStore.swift b/Sources/RunnerBar/RunnerStore.swift index b1599bdb..8b5579ba 100644 --- a/Sources/RunnerBar/RunnerStore.swift +++ b/Sources/RunnerBar/RunnerStore.swift @@ -172,4 +172,3 @@ final class RunnerStore { return busyRunners + idleRunners } } -// swiftlint:enable file_length From 6a3fe6dd16bf90b966ddc79861e1d5da6c049626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20J?= Date: Sat, 9 May 2026 16:17:06 +0200 Subject: [PATCH 31/79] fix: resolve all compile errors across 7 files --- Sources/RunnerBar/ActionGroup.swift | 103 +++++---------------- Sources/RunnerBar/ActiveJob.swift | 42 +-------- Sources/RunnerBar/AddRunnerSheet.swift | 64 +++---------- Sources/RunnerBar/AppDelegate.swift | 43 +++------ Sources/RunnerBar/LocalRunnerScanner.swift | 82 +--------------- Sources/RunnerBar/RunnerStoreState.swift | 48 +++++----- Sources/RunnerBar/StepLogView.swift | 15 +-- 7 files changed, 75 insertions(+), 322 deletions(-) diff --git a/Sources/RunnerBar/ActionGroup.swift b/Sources/RunnerBar/ActionGroup.swift index 82d53864..03da8f90 100644 --- a/Sources/RunnerBar/ActionGroup.swift +++ b/Sources/RunnerBar/ActionGroup.swift @@ -3,25 +3,17 @@ import Foundation // MARK: - GroupStatus -/// Type-safe status for a workflow run group (commit/PR trigger). -/// Mirrors ci-dash.py's group status derivation logic. enum GroupStatus { - /// At least one sibling run is in progress. case inProgress - /// No run is in progress, but at least one is queued. case queued - /// All runs have concluded (or all jobs are done). case completed } // MARK: - WorkflowRunRef -/// Lightweight reference to a single workflow run inside an ActionGroup. -/// Holds only the data needed for display and job fetching — deliberately -/// minimal so the full job list lives on the parent ActionGroup instead. struct WorkflowRunRef: Identifiable { let id: Int - let name: String // workflow file name, e.g. "SonarQube", "vitest" + let name: String let status: String let conclusion: String? let htmlUrl: String? @@ -29,46 +21,21 @@ struct WorkflowRunRef: Identifiable { // MARK: - ActionGroup -/// Represents one **commit / PR trigger**: all GitHub Actions workflow runs -/// that share the same `head_sha`. Mirrors ci-dash.py's "Group" concept from -/// `group_runs()` + `enrich_group()`. -/// -/// Hierarchy: ActionGroup → jobs (flat across all sibling runs) → JobStep → log. -/// `ActionDetailView` drills into the flat job list; `JobDetailView`/`StepLogView` -/// are reused unchanged below that. -struct ActionGroup: Identifiable { - let headSha: String // head_sha — kept as the underlying group identity - let label: String // "#1270" if PR, else "d6281b" (sha[:7]) - let title: String // commit/PR message first line (≤40 chars) +struct ActionGroup: Identifiable, Equatable { + let headSha: String + let label: String + let title: String let headBranch: String? - let repo: String // owner/repo scope + let repo: String - /// All sibling workflow runs sharing this `head_sha`. var runs: [WorkflowRunRef] - - /// Stable unique key: highest run ID in this group. - /// Run IDs are unique and monotonically increasing — immune to head_sha collisions - /// caused by scheduled workflows firing on the same commit. var id: String { String(runs.map { $0.id }.max() ?? 0) } - - /// All jobs across every run in this group, fetched and flattened. - /// This is what `ActionDetailView` renders. var jobs: [ActiveJob] = [] - - /// Timestamps derived from job data, not run-level API fields. - /// Mirrors ci-dash.py's `first_job_started_at` / `last_job_completed_at`. var firstJobStartedAt: Date? var lastJobCompletedAt: Date? - - /// Fallback creation time from the representative run. var createdAt: Date? - - /// Set to `true` when frozen into `actionGroupCache` after completion. var isDimmed: Bool = false - /// Returns a copy of this group with a replacement jobs array. - /// Used in `RunnerStore` to enrich job data without reconstructing the - /// full struct at every call site. func withJobs(_ newJobs: [ActiveJob]) -> ActionGroup { ActionGroup( headSha: headSha, label: label, title: title, headBranch: headBranch, @@ -79,12 +46,8 @@ struct ActionGroup: Identifiable { ) } - // MARK: - Derived properties (match ci-dash.py enrich_group / status_icon) + // MARK: - Derived properties - /// Group status: in_progress if any run is running; queued if any queued - /// but none running; completed otherwise. - /// Also treats the group as completed if all jobs are done, even if the - /// run-level API status lags behind (mirrors ci-dash.py override). var groupStatus: GroupStatus { if jobsTotal > 0, jobs.filter({ $0.conclusion != nil }).count == jobsTotal { return .completed } @@ -93,8 +56,6 @@ struct ActionGroup: Identifiable { return .completed } - /// Group conclusion: only non-nil when every run has concluded. - /// Priority: failure > cancelled > skipped > success. var conclusion: String? { guard runs.allSatisfy({ $0.conclusion != nil }) else { return nil } if runs.contains(where: { $0.conclusion == "failure" }) { return "failure" } @@ -103,34 +64,25 @@ struct ActionGroup: Identifiable { return "success" } - /// Number of jobs with a concluded result across all sibling runs. var jobsDone: Int { jobs.filter { $0.conclusion == "success" || $0.conclusion == "skipped" }.count } - /// Total job count across all sibling runs. var jobsTotal: Int { jobs.count } - /// Human-readable job progress fraction, e.g. "3/5". Returns "—" while jobs load. - var jobProgress: String { jobs.isEmpty ? "—" : "\(jobsDone)/\(jobsTotal)" } + var jobProgress: String { jobs.isEmpty ? "\u{2014}" : "\(jobsDone)/\(jobsTotal)" } - /// Name of the first in-progress job, or first queued, or "—". var currentJobName: String { if let j = jobs.first(where: { $0.status == "in_progress" }) { return j.name } if let j = jobs.first(where: { $0.status == "queued" }) { return j.name } - return "—" + return "\u{2014}" } - /// How long ago the group started, as a short human string, e.g. "3m ago", "1h ago". - /// Uses `firstJobStartedAt` when available, falls back to `createdAt`. - /// Returns "—" if neither timestamp is available. - /// Delegates to `RelativeTimeFormatter` for testable, injectable formatting. var startedAgo: String { - guard let ref = firstJobStartedAt ?? createdAt else { return "—" } + guard let ref = firstJobStartedAt ?? createdAt else { return "\u{2014}" } return RelativeTimeFormatter.string(from: ref) } - /// Elapsed time derived from min(job.startedAt) → max(job.completedAt). var elapsed: String { if let start = firstJobStartedAt { let end = lastJobCompletedAt ?? Date() @@ -146,14 +98,6 @@ struct ActionGroup: Identifiable { return String(format: "%02d:%02d", m, s) } - // MARK: - Progress fraction (model layer — keeps view bodies clean) - - /// Completion fraction 0.0–1.0 for the pie-progress dot, or `nil` (indeterminate) - /// when status is queued or no job data is available. - /// - /// - `nil` → indeterminate (queued / no jobs loaded yet) - /// - `1.0` → completed - /// - `0..<1` → partial (jobsDone / jobsTotal) var progressFraction: Double? { switch groupStatus { case .queued: return nil @@ -163,6 +107,14 @@ struct ActionGroup: Identifiable { return Double(jobsDone) / Double(jobsTotal) } } + + // MARK: - Equatable + static func == (lhs: ActionGroup, rhs: ActionGroup) -> Bool { + lhs.id == rhs.id + && lhs.isDimmed == rhs.isDimmed + && lhs.jobs == rhs.jobs + && lhs.runs.map({ $0.id }) == rhs.runs.map({ $0.id }) + } } // MARK: - Codable helpers (private to this file) @@ -204,8 +156,6 @@ private struct PRRef: Codable { let number: Int } // MARK: - PR label -/// Derives the short identifier for an action group row. -/// Priority: PR number → branch-embedded number → sha[:7]. private func prLabel(from run: RunPayload) -> String { if let pr = run.pullRequests?.first { return "#\(pr.number)" } if let branch = run.headBranch, @@ -218,13 +168,10 @@ private func prLabel(from run: RunPayload) -> String { // MARK: - Fetch + Group -/// Fetches active workflow runs for a repo scope, groups them by `head_sha`, -/// enriches each group with its flattened job list, and returns groups sorted: -/// in_progress first, then queued, then done — newest first. // swiftlint:disable:next function_body_length cyclomatic_complexity func fetchActionGroups(for scope: String, cache: [String: ActionGroup] = [:]) -> [ActionGroup] { guard scope.contains("/") else { - log("fetchActionGroups › skipping org scope \(scope)") + log("fetchActionGroups \u{203A} skipping org scope \(scope)") return [] } @@ -232,7 +179,6 @@ func fetchActionGroups(for scope: String, cache: [String: ActionGroup] = [:]) -> var runPayloads: [RunPayload] = [] var seenIDs = Set() - // Phase 1: fetch in_progress and queued runs. for status in ["in_progress", "queued"] { let endpoint = "repos/\(scope)/actions/runs?status=\(status)&per_page=50" guard @@ -244,13 +190,11 @@ func fetchActionGroups(for scope: String, cache: [String: ActionGroup] = [:]) -> } } - // Group by head_sha. var bySha: [String: [RunPayload]] = [:] for run in runPayloads { bySha[run.headSha, default: []].append(run) } - // Phase 2: merge recently completed runs into EXISTING groups only. if let data = ghAPI("repos/\(scope)/actions/runs?status=completed&per_page=100"), let resp = try? JSONDecoder().decode(ActionRunsResponse.self, from: data) { for run in resp.workflowRuns where seenIDs.insert(run.id).inserted { @@ -319,13 +263,12 @@ func fetchActionGroups(for scope: String, cache: [String: ActionGroup] = [:]) -> return (a.createdAt ?? .distantPast) > (b.createdAt ?? .distantPast) } - log("fetchActionGroups › \(groups.count) group(s) for \(scope)") + log("fetchActionGroups \u{203A} \(groups.count) group(s) for \(scope)") return groups } // MARK: - Private helpers -/// Constructs an `ActiveJob` from a decoded `JobPayload`. func makeActiveJob(from j: JobPayload, iso: ISO8601DateFormatter, isDimmed: Bool = false) -> ActiveJob { let steps: [JobStep] = (j.steps ?? []).enumerated().map { idx, s in @@ -334,8 +277,8 @@ func makeActiveJob(from j: JobPayload, iso: ISO8601DateFormatter, name: s.name, status: s.status, conclusion: s.conclusion, - startedAt: s.startedAt.flatMap { iso.date(from: $0) }, - completedAt: s.completedAt.flatMap { iso.date(from: $0) } + startedAt: s.startedAt, + completedAt: s.completedAt ) } return ActiveJob( @@ -352,7 +295,6 @@ func makeActiveJob(from j: JobPayload, iso: ISO8601DateFormatter, ) } -/// Fetch and decode jobs for a single run ID. private func fetchJobsForRun(_ runID: Int, scope: String, iso: ISO8601DateFormatter) -> [ActiveJob] { guard let data = ghAPI("repos/\(scope)/actions/runs/\(runID)/jobs?filter=latest&per_page=100"), @@ -400,7 +342,6 @@ private func fetchJobsForRun(_ runID: Int, scope: String, iso: ISO8601DateFormat return result } -/// Lower number = higher display priority for sort. private func statusPriority(_ status: GroupStatus) -> Int { switch status { case .inProgress: return 0 diff --git a/Sources/RunnerBar/ActiveJob.swift b/Sources/RunnerBar/ActiveJob.swift index 9a926d6d..b43557b4 100644 --- a/Sources/RunnerBar/ActiveJob.swift +++ b/Sources/RunnerBar/ActiveJob.swift @@ -4,31 +4,17 @@ import Foundation /// Represents a single GitHub Actions job that is live or recently completed. struct ActiveJob: Identifiable, Codable, Equatable { - /// GitHub-assigned job identifier. let id: Int - /// Display name of the job. let name: String - /// Current lifecycle status (`queued`, `in_progress`, `completed`). let status: String - /// Final outcome once the job finishes (`success`, `failure`, `cancelled`, etc.). let conclusion: String? - /// When the job runner picked up the job. let startedAt: Date? - /// When the job was added to the queue. let createdAt: Date? - /// When the job finished. let completedAt: Date? - /// Deep-link URL on github.com for this job. let htmlUrl: String? - /// `true` when the job is shown as a dimmed historical entry. let isDimmed: Bool - /// Ordered list of steps within this job. let steps: [JobStep] - /// Human-readable elapsed time string. - /// Queued jobs always show "00:00". - /// Completed jobs return "--:--" when timestamps are unavailable. - /// Live jobs fall back to createdAt while startedAt may not yet be set. var elapsed: String { guard status != "queued" else { return "00:00" } if conclusion != nil { @@ -48,17 +34,6 @@ struct ActiveJob: Identifiable, Codable, Equatable { return String(format: "%02d:%02d", m, s) } - // MARK: - Progress fraction (model layer — keeps view bodies clean) - - /// Completion fraction 0.0–1.0 based on concluded steps, or `nil` (indeterminate) - /// when status is queued or no step data is available. - /// - /// - `nil` → indeterminate (queued / no steps loaded yet) — renders a centre dot - /// - `1.0` → all steps concluded - /// - `0..<1` → partial (concludedSteps / totalSteps) - /// - /// Consumed by `PieProgressView(progress: job.progressFraction, ...)` in - /// `InlineJobRowView` — mirrors `ActionGroup.progressFraction` at the group level. var progressFraction: Double? { switch status { case "queued": return nil @@ -73,22 +48,14 @@ struct ActiveJob: Identifiable, Codable, Equatable { // MARK: - JobStep -/// A single step within an `ActiveJob`, matching the GitHub API `steps` array. struct JobStep: Identifiable, Codable, Equatable { - /// Step sequence number (1-based). let id: Int - /// Display name of the step. let name: String - /// Lifecycle status of the step. let status: String - /// Conclusion of the step once finished. let conclusion: String? - /// When this step started. let startedAt: Date? - /// When this step finished. let completedAt: Date? - /// SF Symbol or emoji icon representing the step's conclusion. var conclusionIcon: String { switch conclusion { case "success": return "✓" @@ -99,7 +66,6 @@ struct JobStep: Identifiable, Codable, Equatable { } } - /// Human-readable elapsed time for this step. var elapsed: String { let start = startedAt ?? Date() let end = completedAt ?? Date() @@ -120,7 +86,7 @@ struct JobStep: Identifiable, Codable, Equatable { // MARK: - JobPayload (API decoding) -/// Raw API shape for a single job returned by `GET /repos/{owner}/{repo}/actions/jobs/{job_id}`. +/// Raw API shape for a single job — Decodable only (no Encodable needed). struct JobPayload: Decodable { let id: Int let name: String @@ -143,9 +109,7 @@ struct JobPayload: Decodable { // MARK: - ActiveJob factory -/// RunnerStore extension providing the `ActiveJob` factory method. extension RunnerStore { - /// Builds an `ActiveJob` from a decoded `JobPayload`. func makeActiveJob( from payload: JobPayload, iso: ISO8601DateFormatter, @@ -168,5 +132,5 @@ extension RunnerStore { // MARK: - Codable helpers -/// Shared response wrapper used by ActionGroup.swift and RunnerStoreState.swift. -struct JobsResponse: Codable { let jobs: [JobPayload] } +/// Shared response wrapper — Decodable only (JobPayload is not Encodable). +struct JobsResponse: Decodable { let jobs: [JobPayload] } diff --git a/Sources/RunnerBar/AddRunnerSheet.swift b/Sources/RunnerBar/AddRunnerSheet.swift index c221f3c0..327ee366 100644 --- a/Sources/RunnerBar/AddRunnerSheet.swift +++ b/Sources/RunnerBar/AddRunnerSheet.swift @@ -3,30 +3,13 @@ import SwiftUI // swiftlint:disable type_body_length // MARK: - AddRunnerSheet -/// Phase 3: Sheet view for onboarding a new self-hosted runner. -/// -/// The user picks a scope (org or repo), names the runner, optionally sets -/// labels, and taps Confirm. The sheet fetches a registration token via the -/// GitHub API, then runs `./config.sh` in the runner install directory to -/// complete registration. On success it dismisses itself and calls `onComplete` -/// so the caller can re-scan and show the new runner. -/// -/// Requires a GitHub token (`gh auth login`, GH_TOKEN, or GITHUB_TOKEN). struct AddRunnerSheet: View { - /// Binding that controls sheet presentation; set to `false` to dismiss. @Binding var isPresented: Bool - /// Called when registration succeeds so the caller can re-scan runners. let onComplete: () -> Void - // MARK: Scope state - - /// Scope type selection for the new runner: a specific repository or an organisation. enum ScopeType: String, CaseIterable, Identifiable { - /// Register the runner under a specific repository (owner/repo). case repo = "Repository" - /// Register the runner under an entire organisation. case org = "Organisation" - /// Stable identifier for `ForEach` — uses the raw string value. var id: String { rawValue } } @@ -36,23 +19,14 @@ struct AddRunnerSheet: View { @State private var repos: [String] = [] @State private var orgs: [String] = [] @State private var isLoadingScopes = false - - // MARK: Runner config state - @State private var runnerName = "" @State private var labelsText = "self-hosted,macOS" @State private var installDir = (FileManager.default .homeDirectoryForCurrentUser .appendingPathComponent("actions-runner").path) - - // MARK: Registration state - @State private var isRegistering = false @State private var errorMessage: String? - // MARK: - Body - - /// The sheet's root view: scope picker, runner name/labels/dir fields, and Add/Cancel buttons. var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Add runner") @@ -68,13 +42,13 @@ struct AddRunnerSheet: View { if isLoadingScopes { HStack { ProgressView().scaleEffect(0.7) - Text("Loading…").font(.caption).foregroundColor(.secondary) + Text("Loading\u{2026}").font(.caption).foregroundColor(.secondary) } } else if scopeType == .repo { VStack(alignment: .leading, spacing: 4) { Text("Repository").font(.caption).foregroundColor(.secondary) Picker("", selection: $selectedRepo) { - Text("— select —").tag("") + Text("\u{2014} select \u{2014}").tag("") ForEach(repos, id: \.self) { Text($0).tag($0) } } .labelsHidden() @@ -87,7 +61,7 @@ struct AddRunnerSheet: View { VStack(alignment: .leading, spacing: 4) { Text("Organisation").font(.caption).foregroundColor(.secondary) Picker("", selection: $selectedOrg) { - Text("— select —").tag("") + Text("\u{2014} select \u{2014}").tag("") ForEach(orgs, id: \.self) { Text($0).tag($0) } } .labelsHidden() @@ -133,7 +107,7 @@ struct AddRunnerSheet: View { if isRegistering { HStack(spacing: 6) { ProgressView().scaleEffect(0.7) - Text("Registering…") + Text("Registering\u{2026}") } } else { Text("Add Runner") @@ -148,8 +122,6 @@ struct AddRunnerSheet: View { .onAppear(perform: loadScopes) } - // MARK: - Helpers - private var effectiveScope: String { scopeType == .repo ? selectedRepo : selectedOrg } @@ -184,26 +156,22 @@ struct AddRunnerSheet: View { let labels = labelsText.trimmingCharacters(in: .whitespaces) let dir = installDir.trimmingCharacters(in: .whitespaces) DispatchQueue.global(qos: .userInitiated).async { - // Security: validate that installDir resolves to a path inside the - // user's home directory before executing config.sh there. - // A freeform path like ~/../../usr/local/bin could otherwise cause - // an arbitrary executable to be launched with the user's privileges. let homeDir = FileManager.default.homeDirectoryForCurrentUser - .resolvingSymlinksInPath.path + .resolvingSymlinksInPath().path let resolvedDir = URL(fileURLWithPath: dir) - .resolvingSymlinksInPath.path + .resolvingSymlinksInPath().path guard resolvedDir.hasPrefix(homeDir) else { DispatchQueue.main.async { isRegistering = false - errorMessage = "Install directory must be inside your home folder (~/…)." + errorMessage = "Install directory must be inside your home folder (~/\u{2026})." } return } guard let token = fetchRegistrationToken(scope: scope) else { DispatchQueue.main.async { isRegistering = false - errorMessage = "Failed to fetch registration token. " + - "Ensure `gh auth login` has been run or GH_TOKEN is set." + errorMessage = "Failed to fetch registration token. " + + "Ensure `gh auth login` has been run or GH_TOKEN is set." } return } @@ -228,19 +196,13 @@ struct AddRunnerSheet: View { isPresented = false onComplete() } else { - errorMessage = "config.sh failed (exit \(exitCode)). " + - "Check that the token is valid and config.sh is executable." + errorMessage = "config.sh failed (exit \(exitCode)). " + + "Check that the token is valid and config.sh is executable." } } } } - /// Runs `config.sh` via `Process.arguments` so token/name/url are never - /// shell-interpolated. Arguments are passed as discrete array entries. - /// - /// ⚠️ Blocking — must only be called from a background thread. - /// - /// Returns the process exit code (0 = success). private func runRegistrationCommand( dir: String, ghURL: String, @@ -267,7 +229,7 @@ struct AddRunnerSheet: View { } do { try task.run() } catch { pipe.fileHandleForReading.readabilityHandler = nil - log("runRegistrationCommand › launch error: \(error)") + log("runRegistrationCommand \u{203A} launch error: \(error)") return 1 } let timeoutItem = DispatchWorkItem { task.terminate() } @@ -278,7 +240,7 @@ struct AddRunnerSheet: View { let tail = pipe.fileHandleForReading.readDataToEndOfFile() if !tail.isEmpty { lock.lock(); outputData.append(tail); lock.unlock() } let output = String(data: outputData, encoding: .utf8) ?? "" - log("runRegistrationCommand › exit=\(task.terminationStatus): \(output.prefix(120))") + log("runRegistrationCommand \u{203A} exit=\(task.terminationStatus): \(output.prefix(120))") return task.terminationStatus } } diff --git a/Sources/RunnerBar/AppDelegate.swift b/Sources/RunnerBar/AppDelegate.swift index 5e6858e6..a7de540d 100644 --- a/Sources/RunnerBar/AppDelegate.swift +++ b/Sources/RunnerBar/AppDelegate.swift @@ -12,43 +12,29 @@ import SwiftUI // ❌ NEVER add objectWillChange.send() in reload() // ❌ NEVER remove .frame(idealWidth: 340) from PopoverMainView -/// Navigation state machine for the popover's view hierarchy. private enum NavState { - /// Root level: PopoverMainView. case main - /// Jobs path level 2: step list for a job. case jobDetail(ActiveJob) - /// Jobs path level 3: log output for a step. case stepLog(ActiveJob, JobStep) - /// Actions path level 2a: job list for a commit/PR group. case actionDetail(ActionGroup) - /// Actions path level 3a: step list for a job reached via an action group. case actionJobDetail(ActiveJob, ActionGroup) - /// Actions path level 4a: log output for a step reached via an action group. case actionStepLog(ActiveJob, JobStep, ActionGroup) - /// Settings view. case settings } // MARK: - AppDelegate -/// Application delegate. Owns the status-bar item, NSPopover, and navigation state. final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private var statusItem: NSStatusItem? private var popover: NSPopover? private var hostingController: NSHostingController? - private let observable = RunnerStoreObservable() + @MainActor private lazy var observable = RunnerStoreObservable() private var savedNavState: NavState? - - // ⚠️ MUST be set to true BEFORE reload() on open. NEVER remove. private var popoverIsOpen = false - - /// Fixed popover width matching PopoverMainView's .frame(idealWidth: 340). private static let fixedWidth: CGFloat = 340 // MARK: - App lifecycle - /// Bootstraps the status-bar item, hosting controller, and popover at launch. func applicationDidFinishLaunching(_ notification: Notification) { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) if let button = statusItem?.button { @@ -72,15 +58,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { self.statusItem?.button?.image = makeStatusIcon( for: RunnerStore.shared.aggregateStatus ) - if !self.popoverIsOpen { self.observable.reload() } + if !self.popoverIsOpen { + DispatchQueue.main.async { self.observable.reload() } + } } RunnerStore.shared.start() } // MARK: - NSPopoverDelegate - /// Resets navigation state after the popover closes. - /// ❌ NEVER call reload() here. func popoverDidClose(_ notification: Notification) { popoverIsOpen = false DispatchQueue.main.async { [weak self] in @@ -91,7 +77,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // MARK: - View factories - /// Re-fetches step data for `job` if steps are missing or stale. private func enrichStepsIfNeeded(_ job: ActiveJob) -> ActiveJob { guard job.steps.isEmpty || job.steps.contains(where: { $0.status == "in_progress" }), @@ -103,7 +88,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { return makeActiveJob(from: fresh, iso: iso, isDimmed: job.isDimmed) } - /// Navigation level 1: runner status + jobs + actions. + @MainActor private func mainView() -> AnyView { savedNavState = nil return AnyView(PopoverMainView( @@ -130,7 +115,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { )) } - /// Navigation level 2a: flat job list for a commit/PR group. + @MainActor private func actionDetailView(group: ActionGroup) -> AnyView { savedNavState = .actionDetail(group) return AnyView(ActionDetailView( @@ -152,7 +137,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { )) } - /// Navigation level 3a: JobDetailView reached via an ActionGroup. + @MainActor private func detailViewFromAction(job: ActiveJob, group: ActionGroup) -> AnyView { savedNavState = .actionJobDetail(job, group) return AnyView(JobDetailView( @@ -168,7 +153,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { )) } - /// Navigation level 4a: StepLogView reached via an ActionGroup. + @MainActor private func logViewFromAction(job: ActiveJob, step: JobStep, group: ActionGroup) -> AnyView { savedNavState = .actionStepLog(job, step, group) return AnyView(StepLogView( @@ -181,7 +166,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { )) } - /// Navigation level 2: step list for a job (Jobs path). + @MainActor private func detailView(job: ActiveJob) -> AnyView { savedNavState = .jobDetail(job) return AnyView(JobDetailView( @@ -197,7 +182,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { )) } - /// Settings view. + @MainActor private func settingsView() -> AnyView { savedNavState = .settings return AnyView(SettingsView( @@ -209,7 +194,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { )) } - /// Navigation level 3: log output for a step (Jobs path). + @MainActor private func logView(job: ActiveJob, step: JobStep) -> AnyView { savedNavState = .stepLog(job, step) return AnyView(StepLogView( @@ -222,7 +207,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { )) } - /// Returns a refreshed view for `state` using live RunnerStore data, or `nil` if stale. + @MainActor private func validatedView(for state: NavState) -> AnyView? { savedNavState = nil let store = RunnerStore.shared @@ -253,14 +238,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // MARK: - Navigation - /// Swaps the hosting controller's root view. ZERO size changes. Forever. private func navigate(to view: AnyView) { hostingController?.rootView = view } // MARK: - Popover show/hide - /// Toggles the popover open or closed. @objc private func togglePopover() { guard let popover else { return } if popover.isShown { @@ -270,7 +253,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } - /// Opens the popover. The ONE safe site for sizing. + @MainActor private func openPopover() { guard let button = statusItem?.button, button.window != nil, diff --git a/Sources/RunnerBar/LocalRunnerScanner.swift b/Sources/RunnerBar/LocalRunnerScanner.swift index f23ffa80..d92e4e8a 100644 --- a/Sources/RunnerBar/LocalRunnerScanner.swift +++ b/Sources/RunnerBar/LocalRunnerScanner.swift @@ -2,25 +2,7 @@ import Foundation // MARK: - LocalRunnerScanner -/// Discovers locally-installed GitHub Actions self-hosted runners without -/// requiring a GitHub API token. Uses three complementary scan sources: -/// -/// 1. **LaunchAgents** — `~/Library/LaunchAgents/actions.runner.*.plist` -/// Parses `owner`, `repo`, and `runnerName` from the plist filename. -/// -/// 2. **`.runner` JSON files** — `find ~ /opt /usr/local -maxdepth 6 -name ".runner"` -/// Reads `gitHubUrl`, `runnerName`, `agentId`, and `workFolder` from each -/// file. This is the most authoritative local source. -/// -/// 3. **Live service check** — `launchctl list | grep actions.runner` -/// Flags which runners currently have an active launchd service, indicating -/// they are registered and running. Service labels embed owner, repo, and -/// runnerName, so runners with identical names but different scopes are -/// correctly distinguished — no cross-runner contamination. struct LocalRunnerScanner { - // MARK: - .runner JSON schema - - /// Decodable mirror of the relevant fields inside a `.runner` JSON file. private struct RunnerJSON: Decodable { let gitHubUrl: String? let runnerName: String? @@ -28,49 +10,28 @@ struct LocalRunnerScanner { let workFolder: String? } - // MARK: - Public API - - /// Performs the full 3-source scan and returns deduplicated `RunnerModel` results. - /// This is a synchronous, blocking call — always invoke from a background thread. func scan() -> [RunnerModel] { - var models: [String: RunnerModel] = [] + var models: [String: RunnerModel] = [:] - // Source 2 first: .runner JSON is most authoritative — richer data. - // JSON models are inserted under their stable id (agentId or composite). for model in scanRunnerJSONFiles() { models[model.id] = model } - // Source 1: LaunchAgents — fills in runners whose .runner file wasn't found. - // Skip if a JSON model already covers the same runner: detect overlap by - // checking whether any existing JSON entry has the same runnerName+gitHubUrl - // composite key as the LaunchAgent candidate. This prevents a second dict - // entry for the same physical runner and avoids SwiftUI id churn on the - // next scan (when JSON supersedes a previously LaunchAgent-only entry). for model in scanLaunchAgents() { let compositeKey = "\(model.runnerName)-\(model.gitHubUrl ?? "")" let alreadyCoveredByJSON = models.values.contains { existing in - // A JSON model covers this LaunchAgent entry when its - // runnerName+gitHubUrl composite matches — regardless of whether - // the JSON model's id is agentId-based or composite. let existingComposite = "\(existing.runnerName)-\(existing.gitHubUrl ?? "")" return existingComposite == compositeKey } guard !alreadyCoveredByJSON else { continue } - // Only insert if no entry exists yet under this composite key. if models[compositeKey] == nil { models[compositeKey] = model } } - // Source 3: mark which runners are live. let liveLabels = scanLiveServices() for key in models.keys { - // Safe optional binding — key is guaranteed to exist but avoids force-unwrap. if let model = models[key] { - // Match by service label which contains the runner name. This is - // scope-aware: two runners with the same name but different - // owner/repo get distinct labels and are matched independently. models[key]?.isRunning = liveLabels.contains { $0.contains(model.runnerName) } } } @@ -78,15 +39,6 @@ struct LocalRunnerScanner { return models.values.sorted { $0.runnerName < $1.runnerName } } - // MARK: - Source 1: LaunchAgents - - /// Scans `~/Library/LaunchAgents` for plist files matching the pattern - /// `actions.runner....plist` and returns a minimal - /// `RunnerModel` for each one found. - /// - /// Parsing splits on the `"actions.runner."` prefix rather than on every `.` - /// so that dotted owner, repo, or runner names (e.g. `my.org/my.repo`) are - /// handled correctly without silently mis-parsing the components. private func scanLaunchAgents() -> [RunnerModel] { let dir = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents") @@ -97,15 +49,8 @@ struct LocalRunnerScanner { let prefix = "actions.runner." return entries.compactMap { url -> RunnerModel? in let filename = url.deletingPathExtension().lastPathComponent - // Only process files whose names start with the known prefix. guard filename.hasPrefix(prefix) else { return nil } - // Strip the prefix, leaving ".." (or just - // "." for runners registered at org level). let remainder = String(filename.dropFirst(prefix.count)) - // The remainder uses the first two dot-separated tokens as owner and - // repo; everything after the second dot is the runner name. This is - // still an approximation for dotted owner/repo names, but it is - // significantly more robust than splitting the whole filename on ".". let parts = remainder.components(separatedBy: ".") guard parts.count >= 2 else { return nil } let owner = parts[0] @@ -123,15 +68,7 @@ struct LocalRunnerScanner { } } - // MARK: - Source 2: .runner JSON files - - /// Uses `find` to locate `.runner` JSON files in common install locations - /// and decodes each one into a `RunnerModel`. private func scanRunnerJSONFiles() -> [RunnerModel] { - // -maxdepth MUST precede -name: on macOS BSD find, placing -maxdepth - // after a predicate applies the depth limit only to subtrees past that - // point, so the guard would not work and could traverse node_modules etc. - // shell() is defined in Shell.swift. let raw = shell( "find ~ /opt /usr/local -maxdepth 6 -name '.runner' 2>/dev/null", timeout: 15 @@ -157,21 +94,7 @@ struct LocalRunnerScanner { } } - // MARK: - Source 3: Live service check - - /// Returns the set of launchd service labels for runner services that are - /// currently loaded and running, using `launchctl list | grep actions.runner`. - /// - /// This replaces the previous `ps aux | grep Runner.Listener` approach, which - /// was broken because `Runner.Listener` does not pass `--runnerName` — so the - /// old parsing never matched and `isRunning` was always `false`. - /// - /// Using launchctl service labels also fixes cross-runner contamination: two - /// runners with the same `runnerName` but different owner/repo get distinct - /// labels (e.g. `actions.runner.orgA.repoA.my-runner` vs - /// `actions.runner.orgB.repoB.my-runner`) and are matched independently. private func scanLiveServices() -> Set { - // shell() is defined in Shell.swift. let output = shell( "launchctl list 2>/dev/null | grep actions.runner", timeout: 5 @@ -180,13 +103,10 @@ struct LocalRunnerScanner { var labels = Set() for line in output.components(separatedBy: "\n") where !line.isEmpty { - // launchctl list output: "\t\t