diff --git a/.gitignore b/.gitignore index 3563a1e..e83713c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ project.xcworkspace # Swift Package Manager .build/ +build/ Packages/ Package.pins Package.resolved @@ -43,6 +44,9 @@ fastlane/test_output # Code Injection iOSInjectionProject/ +# VS Code +.vscode/ + # macOS .DS_Store .AppleDouble diff --git a/Makefile b/Makefile index 554af62..5a40347 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ build: # Build and run run: build - .build/debug/MacRunner + .build/debug/mac-runner # Clean build artifacts clean: @@ -22,7 +22,7 @@ app: release @echo "Creating app bundle..." @mkdir -p build/MacRunner.app/Contents/MacOS @mkdir -p build/MacRunner.app/Contents/Resources - @cp .build/release/MacRunner build/MacRunner.app/Contents/MacOS/ + @cp .build/apple/Products/Release/mac-runner build/MacRunner.app/Contents/MacOS/MacRunner @./scripts/generate-info-plist.sh > build/MacRunner.app/Contents/Info.plist @echo "App bundle created at build/MacRunner.app" diff --git a/Sources/Views/MenuBarView.swift b/Sources/Views/MenuBarView.swift index ff6f740..23d4f0a 100644 --- a/Sources/Views/MenuBarView.swift +++ b/Sources/Views/MenuBarView.swift @@ -3,6 +3,8 @@ import SwiftUI struct MenuBarView: View { @EnvironmentObject var runnerManager: RunnerManager @State private var showAddRunner = false + @State private var collapsedTargets: Set = [] + @State private var dismissedUpdateVersion: String? var body: some View { VStack(spacing: 0) { @@ -83,32 +85,53 @@ struct MenuBarView: View { ScrollView { VStack(alignment: .leading, spacing: 4) { ForEach(groupedRunners, id: \.target) { group in + let isCollapsed = collapsedTargets.contains(group.target) + // Section header — org-level groups use a building icon to distinguish - // them from repo-level groups at a glance. - HStack(spacing: 4) { - Image(systemName: group.target.scope == .org ? "building.2" : "folder") - .font(.caption2) - .foregroundColor(.secondary) - Text(group.target.displayName) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.secondary) - Spacer() - Text("\(group.runners.count)") - .font(.caption2) - .foregroundColor(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 1) - .background(Color.gray.opacity(0.2)) - .cornerRadius(4) + // them from repo-level groups at a glance. Tapping toggles the group. + Button(action: { + withAnimation(.easeInOut(duration: 0.15)) { + if isCollapsed { + collapsedTargets.remove(group.target) + } else { + collapsedTargets.insert(group.target) + } + } + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundColor(.secondary) + .rotationEffect(.degrees(isCollapsed ? 0 : 90)) + + Image(systemName: group.target.scope == .org ? "building.2" : "folder") + .font(.caption2) + .foregroundColor(.secondary) + Text(group.target.displayName) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + Spacer() + Text("\(group.runners.count)") + .font(.caption2) + .foregroundColor(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Color.gray.opacity(0.2)) + .cornerRadius(4) + } + .contentShape(Rectangle()) } + .buttonStyle(.plain) .padding(.horizontal, 12) .padding(.top, 8) .padding(.bottom, 2) - ForEach(group.runners) { runner in - RunnerRow(runner: runner) - .environmentObject(runnerManager) + if !isCollapsed { + ForEach(group.runners) { runner in + RunnerRow(runner: runner) + .environmentObject(runnerManager) + } } } } @@ -151,38 +174,50 @@ struct MenuBarView: View { .frame(maxWidth: .infinity, alignment: .leading) } - if let update = runnerManager.availableUpdate { - Button(action: { - Task { - await runnerManager.performAvailableUpdate() - } - }) { - HStack(spacing: 10) { - if runnerManager.isInstallingUpdate { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "arrow.down.circle.fill") - .foregroundColor(.accentColor) + if let update = runnerManager.availableUpdate, update.latestVersion != dismissedUpdateVersion { + HStack(spacing: 10) { + Button(action: { + Task { + await runnerManager.performAvailableUpdate() } + }) { + HStack(spacing: 10) { + if runnerManager.isInstallingUpdate { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.down.circle.fill") + .foregroundColor(.accentColor) + } - VStack(alignment: .leading, spacing: 2) { - Text(update.actionTitle) - .font(.caption) - .fontWeight(.semibold) - Text(update.detailText) - .font(.caption2) - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(update.actionTitle) + .font(.caption) + .fontWeight(.semibold) + Text(update.detailText) + .font(.caption2) + .foregroundColor(.secondary) + } } + } + .buttonStyle(.plain) + .disabled(runnerManager.isInstallingUpdate) - Spacer() + Spacer() + + Button(action: { + dismissedUpdateVersion = update.latestVersion + }) { + Image(systemName: "xmark") + .font(.caption2) + .foregroundColor(.secondary) } - .padding(10) - .background(Color.accentColor.opacity(0.1)) - .cornerRadius(8) + .buttonStyle(.plain) + .help("Dismiss") } - .buttonStyle(.plain) - .disabled(runnerManager.isInstallingUpdate) + .padding(10) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(8) } HStack { @@ -290,11 +325,13 @@ struct RunnerRow: View { .help("Open current GitHub Actions run") } - HStack(spacing: 4) { + FlowLayout(spacing: 4) { // Isolation mode indicator if let mode = runner.isolationMode { Text("\(mode.icon) \(mode.displayName)") .font(.caption2) + .lineLimit(1) + .fixedSize() .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.purple.opacity(0.2)) @@ -302,6 +339,8 @@ struct RunnerRow: View { } else { Text("🌐 Global") .font(.caption2) + .lineLimit(1) + .fixedSize() .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.gray.opacity(0.2)) @@ -312,6 +351,8 @@ struct RunnerRow: View { if runner.enableGUI { Text("🖥️ GUI") .font(.caption2) + .lineLimit(1) + .fixedSize() .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.green.opacity(0.2)) @@ -319,6 +360,8 @@ struct RunnerRow: View { } else { Text("⚫ Headless") .font(.caption2) + .lineLimit(1) + .fixedSize() .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.gray.opacity(0.2)) @@ -328,6 +371,8 @@ struct RunnerRow: View { ForEach(runner.labels, id: \.self) { label in Text(label) .font(.caption2) + .lineLimit(1) + .fixedSize() .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.blue.opacity(0.2)) @@ -342,8 +387,7 @@ struct RunnerRow: View { .lineLimit(2) } } - - Spacer() + .frame(maxWidth: .infinity, alignment: .leading) // Inline start/stop button Button(action: { @@ -356,7 +400,11 @@ struct RunnerRow: View { } }) { Image(systemName: runner.status == .running ? "stop.fill" : "play.fill") - .foregroundColor(runner.status == .running ? .red : .green) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.primary) + .frame(width: 26, height: 26) + .background(Circle().fill(Color.primary.opacity(0.08))) + .overlay(Circle().strokeBorder(Color.primary.opacity(0.2), lineWidth: 1)) } .buttonStyle(.plain) .help(runner.status == .running ? "Stop" : "Start") @@ -652,3 +700,48 @@ struct SettingsView: View { MenuBarView() .environmentObject(RunnerManager()) } + +/// Lays out subviews left-to-right, wrapping onto additional lines when a row would exceed the available width. +struct FlowLayout: Layout { + var spacing: CGFloat = 4 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var origin = CGPoint.zero + var rowHeight: CGFloat = 0 + var totalHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if origin.x > 0, origin.x + size.width > maxWidth { + origin.x = 0 + origin.y += rowHeight + spacing + totalHeight += rowHeight + spacing + rowHeight = 0 + } + origin.x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + totalHeight += rowHeight + + return CGSize(width: maxWidth.isFinite ? maxWidth : origin.x, height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let maxWidth = bounds.width + var origin = CGPoint(x: bounds.minX, y: bounds.minY) + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if origin.x > bounds.minX, origin.x - bounds.minX + size.width > maxWidth { + origin.x = bounds.minX + origin.y += rowHeight + spacing + rowHeight = 0 + } + subview.place(at: origin, proposal: .unspecified) + origin.x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +}