import AppKit import SwiftUI /// The small job window. Tangerine opens one per job; a single stack that /// shows every job is less noisy and can be dismissed in one click. final class JobPanelController: NSObject { static let shared = JobPanelController() private var panel: NSPanel? private override init() { super.init() } func refresh(store: JobStore) { guard Thread.isMainThread else { DispatchQueue.main.async { self.refresh(store: store) } return } guard Settings.shared.showProgressWindow else { return } guard !store.jobs.isEmpty else { panel?.orderOut(nil) panel = nil return } let panel = ensurePanel() if !panel.isVisible { position(panel, near: store.jobs.last?.origin) panel.alphaValue = 0 panel.orderFrontRegardless() NSAnimationContext.runAnimationGroup { context in context.duration = 0.18 panel.animator().alphaValue = 1 } } } func dismissIfAllFinished(store: JobStore) { guard store.activeCount == 0 else { return } let work = DispatchWorkItem { [weak self] in guard let self, let panel = self.panel, JobStore.shared.activeCount == 0 else { return } NSAnimationContext.runAnimationGroup { context in context.duration = 0.2 panel.animator().alphaValue = 0 } completionHandler: { panel.orderOut(nil) JobStore.shared.dismissFinished() self.panel = nil } } DispatchQueue.main.asyncAfter(deadline: .now() + 1.6, execute: work) } func show() { refresh(store: .shared) } private func ensurePanel() -> NSPanel { if let panel { return panel } let panel = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 320, height: 120), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) panel.isFloatingPanel = true panel.level = .floating panel.isOpaque = false panel.backgroundColor = .clear panel.hasShadow = true panel.hidesOnDeactivate = false panel.becomesKeyOnlyIfNeeded = true panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.contentView = NSHostingView(rootView: JobsView(store: .shared)) self.panel = panel return panel } private func position(_ panel: NSPanel, near origin: CGPoint?) { let size = panel.contentView?.fittingSize ?? CGSize(width: 320, height: 140) panel.setContentSize(size) let screen = NSScreen.screens.first { $0.frame.contains(origin ?? .zero) } ?? NSScreen.main guard let frame = screen?.visibleFrame else { return } var point = CGPoint(x: frame.midX - size.width / 2, y: frame.minY + 80) if let origin { point = CGPoint(x: origin.x + 26, y: origin.y - size.height / 2) } point.x = min(max(frame.minX + 12, point.x), frame.maxX - size.width - 12) point.y = min(max(frame.minY + 12, point.y), frame.maxY - size.height - 12) panel.setFrameOrigin(point) } /// Re-centres and resizes once the content changes. func layout() { guard let panel, panel.isVisible else { return } let size = panel.contentView?.fittingSize ?? panel.frame.size let anchor = CGPoint(x: panel.frame.minX, y: panel.frame.midY) panel.setContentSize(size) panel.setFrameOrigin(CGPoint(x: anchor.x, y: anchor.y - size.height / 2)) } } struct JobsView: View { @ObservedObject var store: JobStore var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 6) { Image(systemName: "arrow.triangle.2.circlepath") .font(.system(size: 11, weight: .semibold)) Text(store.activeCount > 0 ? "Working…" : "Finished") .font(.system(size: 11, weight: .semibold)) Spacer() Button { store.cancelAll() } label: { Image(systemName: "stop.circle") } .buttonStyle(.plain) .help("Cancel everything") .opacity(store.activeCount > 0 ? 1 : 0) Button { store.dismissFinished() JobPanelController.shared.performClose() } label: { Image(systemName: "xmark.circle") } .buttonStyle(.plain) .help("Close") } .padding(.horizontal, 12) .padding(.top, 10) .padding(.bottom, 6) Divider().opacity(0.4) ScrollView { VStack(alignment: .leading, spacing: 8) { ForEach(store.jobs) { job in JobRow(job: job, store: store) } } .padding(12) } .frame(maxHeight: 260) } .frame(width: 320) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(Color.primary.opacity(0.09)) ) .onReceive(store.$jobs) { _ in DispatchQueue.main.async { JobPanelController.shared.layout() } } } } private struct JobRow: View { let job: Job @ObservedObject var store: JobStore var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Image(systemName: symbol) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(tint) Text(job.title) .font(.system(size: 11.5, weight: .medium)) .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 4) if job.status == .running { Button { store.cancel(job.id) } label: { Image(systemName: "xmark") .font(.system(size: 9, weight: .bold)) } .buttonStyle(.plain) .help("Cancel this job") } else { Button { store.remove(job.id) } label: { Image(systemName: "trash") .font(.system(size: 9)) } .buttonStyle(.plain) .help("Remove") } } if job.status == .running { ProgressView(value: max(0.02, job.progress)) .progressViewStyle(.linear) } HStack(spacing: 6) { Text(detail) .font(.system(size: 10)) .foregroundStyle(.secondary) .lineLimit(2) Spacer(minLength: 4) if !job.outputs.isEmpty, job.status == .finished { Button("Reveal") { NSWorkspace.shared.activateFileViewerSelecting(job.outputs) } .buttonStyle(.plain) .font(.system(size: 10, weight: .medium)) .foregroundStyle(Color.accentColor) } } } } private var detail: String { if let error = job.errorMessage { return error } if job.sourceCount > 1, job.status == .finished, !job.outputs.isEmpty { return "\(job.outputs.count) files written" } return job.detail.isEmpty ? job.title : job.detail } private var symbol: String { switch job.status { case .running: return "hourglass" case .finished: return "checkmark.circle.fill" case .failed: return "exclamationmark.triangle.fill" case .cancelled: return "slash.circle" } } private var tint: Color { switch job.status { case .running: return .secondary case .finished: return .green case .failed: return .orange case .cancelled: return .secondary } } } extension JobPanelController { func performClose() { panel?.orderOut(nil) panel = nil JobStore.shared.dismissFinished() } }