import SwiftUI import SwiftData struct JobListView: View { @Environment(\.modelContext) private var modelContext @EnvironmentObject private var automation: BackgroundSyncManager @Query(sort: \SyncJob.name) private var jobs: [SyncJob] @Binding var selection: SyncJob? @Binding var showingNewJob: Bool @State private var searchText = "" @State private var filter: JobFilter = .all @State private var persistenceError: String? var body: some View { VStack(spacing: 0) { dashboardHeader Picker("Filter", selection: $filter) { ForEach(JobFilter.allCases) { item in Text(item.title).tag(item) } } .pickerStyle(.segmented) .labelsHidden() .padding(.horizontal, 14) .padding(.bottom, 10) Divider() if filteredJobs.isEmpty { emptyState } else { List(selection: $selection) { ForEach(filteredJobs) { job in JobRow(job: job, isRunning: automationActivityMatches(job)) .tag(job) .listRowInsets(EdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10)) .contextMenu { Button("Open Job") { selection = job } Button("Duplicate") { duplicate(job) } Divider() Button(job.isEnabled ? "Disable" : "Enable") { job.isEnabled.toggle() job.updatedAt = Date() saveAndRefresh() } Button("Delete", role: .destructive) { delete(job) } } } } .listStyle(.sidebar) } } .alert("Database Error", isPresented: Binding( get: { persistenceError != nil }, set: { if !$0 { persistenceError = nil } } )) { Button("OK", role: .cancel) { persistenceError = nil } } message: { Text(persistenceError ?? "Unable to save job changes.") } } private var dashboardHeader: some View { VStack(alignment: .leading, spacing: 12) { HStack { VStack(alignment: .leading, spacing: 2) { Text("Jobs") .font(.headline) Text("\(jobs.count) configured job\(jobs.count == 1 ? "" : "s")") .font(.caption) .foregroundStyle(.secondary) } Spacer() statusPill Button { showingNewJob = true } label: { Image(systemName: "plus") } .buttonStyle(.borderedProminent) .help("New Sync Job") } HStack(spacing: 8) { TextField("Search jobs", text: $searchText) .textFieldStyle(.roundedBorder) Button { automation.syncAllEnabledJobs() } label: { Label("Sync All", systemImage: "arrow.triangle.2.circlepath") } .buttonStyle(.bordered) .disabled(jobs.allSatisfy { !$0.isEnabled } || automation.isSyncAllRunning) .help("Run every enabled sync job in sequence") } HStack(spacing: 9) { metricCard(value: automation.scheduledJobCount, title: "Scheduled", icon: "calendar.badge.clock", colour: .blue) metricCard(value: automation.watchedJobCount, title: "Watching", icon: "eye.fill", colour: .purple) } } .padding(14) } @ViewBuilder private var statusPill: some View { if automation.activity == .idle { Label("Ready", systemImage: "checkmark.circle.fill") .font(.caption.weight(.semibold)) .foregroundStyle(.green) .padding(.horizontal, 9) .padding(.vertical, 5) .background(Color.green.opacity(0.10), in: Capsule()) } else { Label(automation.activityDisplayTitle, systemImage: "arrow.triangle.2.circlepath") .font(.caption.weight(.semibold)) .foregroundStyle(Color.accentColor) .padding(.horizontal, 9) .padding(.vertical, 5) .background(Color.accentColor.opacity(0.10), in: Capsule()) } } private func metricCard(value: Int, title: String, icon: String, colour: Color) -> some View { HStack(spacing: 9) { ZStack { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(colour.opacity(0.11)) Image(systemName: icon) .foregroundStyle(colour) } .frame(width: 30, height: 30) VStack(alignment: .leading, spacing: 0) { Text("\(value)") .font(.headline) Text(title) .font(.caption2) .foregroundStyle(.secondary) } Spacer(minLength: 0) } .padding(9) .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(Color.secondary.opacity(0.10)) } } private var emptyState: some View { ContentUnavailableView( searchText.isEmpty ? "No Jobs" : "No Matching Jobs", systemImage: searchText.isEmpty ? "arrow.left.arrow.right.square" : "magnifyingglass", description: Text(searchText.isEmpty ? "Create a sync job to get started." : "Try a different search or filter.") ) .frame(maxWidth: .infinity, maxHeight: .infinity) } private var filteredJobs: [SyncJob] { jobs.filter { job in let matchesSearch = searchText.isEmpty || job.name.localizedCaseInsensitiveContains(searchText) || job.sourcePath.localizedCaseInsensitiveContains(searchText) || job.destinationDisplay.localizedCaseInsensitiveContains(searchText) let matchesFilter: Bool switch filter { case .all: matchesFilter = true case .scheduled: matchesFilter = job.scheduleEnabled && job.scheduleKind != .manual case .watching: matchesFilter = job.watchEnabled case .attention: matchesFilter = job.lastExitCode != nil && job.lastExitCode != 0 } return matchesSearch && matchesFilter } } private func automationActivityMatches(_ job: SyncJob) -> Bool { if case .running(let name) = automation.activity { return name == job.name } return false } private func duplicate(_ job: SyncJob) { let copy = SyncJob(name: job.name + " Copy", sourcePath: job.sourcePath, destinationPath: job.destinationPath) copy.isRemote = job.isRemote copy.remoteHost = job.remoteHost copy.remoteUser = job.remoteUser copy.remotePort = job.remotePort copy.sshKeyPath = job.sshKeyPath copy.archive = job.archive copy.compress = job.compress copy.deleteExtraneous = job.deleteExtraneous copy.preservePermissions = job.preservePermissions copy.preserveExtendedAttributes = job.preserveExtendedAttributes copy.showProgress = job.showProgress copy.exclusionsText = job.exclusionsText copy.additionalArguments = job.additionalArguments copy.isEnabled = job.isEnabled copy.scheduleEnabled = job.scheduleEnabled copy.scheduleKind = job.scheduleKind copy.scheduleStartAt = job.scheduleStartAt copy.scheduleIntervalMinutes = job.scheduleIntervalMinutes copy.nextRunAt = copy.calculateNextRun(after: Date().addingTimeInterval(-1)) copy.watchEnabled = job.watchEnabled copy.watchPath = job.watchPath copy.watchDebounceSeconds = job.watchDebounceSeconds copy.allowUnattendedDelete = job.allowUnattendedDelete modelContext.insert(copy) saveAndRefresh() selection = copy } private func delete(_ job: SyncJob) { if selection?.id == job.id { selection = nil } modelContext.delete(job) saveAndRefresh() } private func saveAndRefresh() { do { try modelContext.save() NotificationCenter.default.post(name: .syncJobsChanged, object: nil) } catch { persistenceError = error.localizedDescription } } } private struct JobRow: View { let job: SyncJob let isRunning: Bool var body: some View { HStack(alignment: .top, spacing: 12) { ZStack { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(iconColour.opacity(0.12)) Image(systemName: job.isRemote ? "server.rack" : "folder.fill") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(iconColour) } .frame(width: 38, height: 38) VStack(alignment: .leading, spacing: 6) { HStack(spacing: 7) { Text(job.name) .font(.body.weight(.semibold)) .lineLimit(1) if isRunning { AnimatedSyncIndicator(size: 15, lineWidth: 2.2) } Spacer() statusIcon } pathSummary HStack(spacing: 6) { if job.scheduleEnabled && job.scheduleKind != .manual { miniBadge(job.scheduleKind.title, icon: "calendar.badge.clock", colour: .blue) } if job.watchEnabled { miniBadge("Watching", icon: "eye.fill", colour: .purple) } if !job.isEnabled { miniBadge("Disabled", icon: "pause.fill", colour: .secondary) } } Text(timingText) .font(.caption2) .foregroundStyle(.tertiary) .lineLimit(1) } } .padding(10) .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.secondary.opacity(0.09)) } .opacity(job.isEnabled ? 1 : 0.62) } private var pathSummary: some View { HStack(spacing: 5) { Text(shortPath(job.sourcePath)) .lineLimit(1) .truncationMode(.middle) Image(systemName: "arrow.right") .font(.caption2.weight(.semibold)) .foregroundStyle(.tertiary) Text(shortPath(job.destinationDisplay)) .lineLimit(1) .truncationMode(.middle) } .font(.caption) .foregroundStyle(.secondary) } @ViewBuilder private var statusIcon: some View { if isRunning { Image(systemName: "arrow.triangle.2.circlepath.circle.fill") .foregroundStyle(Color.accentColor) } else if job.lastExitCode == 0 { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) } else if job.lastExitCode != nil { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } else { Image(systemName: "circle.dashed") .foregroundStyle(.tertiary) } } private var timingText: String { if let next = job.nextRunAt, job.scheduleEnabled { return "Next run \(next.formatted(.relative(presentation: .named)))" } if let last = job.lastRunAt { return "Last run \(last.formatted(.relative(presentation: .named)))" } return "Not run yet" } private var iconColour: Color { if !job.isEnabled { return .secondary } return job.isRemote ? .indigo : .blue } private func shortPath(_ path: String) -> String { guard path.count > 42 else { return path } return "…" + path.suffix(41) } private func miniBadge(_ title: String, icon: String, colour: Color) -> some View { Label(title, systemImage: icon) .font(.system(size: 9, weight: .semibold)) .foregroundStyle(colour) .padding(.horizontal, 6) .padding(.vertical, 3) .background(colour.opacity(0.10), in: Capsule()) } } private enum JobFilter: String, CaseIterable, Identifiable { case all, scheduled, watching, attention var id: String { rawValue } var title: String { switch self { case .all: return "All" case .scheduled: return "Scheduled" case .watching: return "Watching" case .attention: return "Issues" } } }