Files
RSync-Studio/Rsync Studio/Views/Jobs/JobListView.swift
T

524 lines
22 KiB
Swift
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import SwiftData
import AppKit
import UniformTypeIdentifiers
struct JobListView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject private var automation: BackgroundSyncCoordinator
@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?
@State private var transferMessage: 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.")
}
.alert("Job Configuration", isPresented: Binding(
get: { transferMessage != nil },
set: { if !$0 { transferMessage = nil } }
)) {
Button("OK", role: .cancel) { transferMessage = nil }
} message: {
Text(transferMessage ?? "")
}
}
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
Menu {
Button("Import Jobs…", systemImage: "square.and.arrow.down") { importJobs() }
Button("Export Jobs…", systemImage: "square.and.arrow.up") { exportJobs() }
} label: {
Image(systemName: "ellipsis.circle")
}
.menuStyle(.borderlessButton)
.help("Import or export job configurations")
Button {
showingNewJob = true
} label: {
Image(systemName: "plus")
}
.buttonStyle(.borderedProminent)
.help("New Sync Job")
}
HStack(spacing: 8) {
TextField("Search jobs", text: $searchText)
.textFieldStyle(.roundedBorder)
if automation.isSyncAllRunning {
Button(role: .destructive) {
automation.cancelSyncAll()
} label: {
Label("Cancel All", systemImage: "stop.fill")
}
.buttonStyle(.bordered)
.help("Cancel the current Sync All batch")
} else {
Button {
automation.syncAllEnabledJobs()
} label: {
Label("Sync All", systemImage: "arrow.triangle.2.circlepath")
}
.buttonStyle(.bordered)
.disabled(jobs.allSatisfy { !$0.isEnabled })
.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)
}
if automation.isSyncAllRunning {
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: Double(automation.syncAllCompleted + automation.syncAllFailed + automation.syncAllSkipped), total: Double(max(1, automation.syncAllTotal)))
HStack {
Text("Sync All · \(automation.syncAllCompleted + automation.syncAllFailed + automation.syncAllSkipped) of \(automation.syncAllTotal)")
Spacer()
Text("✓ \(automation.syncAllCompleted) ! \(automation.syncAllFailed) – \(automation.syncAllSkipped)")
}
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.padding(14)
}
@ViewBuilder
private var statusPill: some View {
if !automation.isNetworkAvailable {
Label("Offline", systemImage: "wifi.slash")
.font(.caption.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(Color.orange.opacity(0.10), in: Capsule())
} else 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
copy.retryCount = job.retryCount
copy.retryDelaySeconds = job.retryDelaySeconds
copy.notifyOnFailure = job.notifyOnFailure
copy.notifyOnSuccess = job.notifyOnSuccess
copy.requireExpectedVolume = job.requireExpectedVolume
copy.expectedVolumeIdentifier = job.expectedVolumeIdentifier
copy.minimumFreeSpaceMB = job.minimumFreeSpaceMB
modelContext.insert(copy)
saveAndRefresh()
selection = copy
}
private func exportJobs() {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "Rsync Studio-Jobs.json"
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let document = JobTransferDocument(version: 1, exportedAt: Date(), jobs: jobs.map(JobTransferJob.init))
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
try encoder.encode(document).write(to: url, options: .atomic)
transferMessage = "Exported \(jobs.count) job configuration(s). SSH private-key paths are intentionally excluded."
} catch { persistenceError = "Could not export jobs: \(error.localizedDescription)" }
}
private func importJobs() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let document = try decoder.decode(JobTransferDocument.self, from: Data(contentsOf: url))
guard document.version == 1 else { throw JobTransferError.unsupportedVersion }
var count = 0
for item in document.jobs {
let job = item.makeJob()
modelContext.insert(job)
count += 1
}
try modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
transferMessage = "Imported \(count) job configuration(s). Remote jobs are disabled until you select a private key or confirm SSH-agent access."
} catch { persistenceError = "Could not import jobs: \(error.localizedDescription)" }
}
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"
}
}
}
private struct JobTransferDocument: Codable {
let version: Int
let exportedAt: Date
let jobs: [JobTransferJob]
}
private struct JobTransferJob: Codable {
let name, sourcePath, destinationPath: String
let isRemote: Bool
let remoteHost, remoteUser: String
let remotePort: Int
let archive, compress, deleteExtraneous, preservePermissions, preserveExtendedAttributes, showProgress: Bool
let exclusionsText, additionalArguments: String
let isEnabled: Bool
let scheduleEnabled: Bool
let scheduleKindRaw: String
let scheduleStartAt: Date
let scheduleIntervalMinutes: Int
let watchEnabled: Bool
let watchPath: String
let watchDebounceSeconds: Int
let allowUnattendedDelete: Bool
let retryCount, retryDelaySeconds: Int
let notifyOnFailure, notifyOnSuccess: Bool
let requireExpectedVolume: Bool
let expectedVolumeIdentifier: String
let minimumFreeSpaceMB: Int
init(_ job: SyncJob) {
name = job.name; sourcePath = job.sourcePath; destinationPath = job.destinationPath
isRemote = job.isRemote; remoteHost = job.remoteHost; remoteUser = job.remoteUser; remotePort = job.remotePort
archive = job.archive; compress = job.compress; deleteExtraneous = job.deleteExtraneous
preservePermissions = job.preservePermissions; preserveExtendedAttributes = job.preserveExtendedAttributes; showProgress = job.showProgress
exclusionsText = job.exclusionsText; additionalArguments = job.additionalArguments; isEnabled = job.isEnabled
scheduleEnabled = job.scheduleEnabled; scheduleKindRaw = job.scheduleKindRaw; scheduleStartAt = job.scheduleStartAt; scheduleIntervalMinutes = job.scheduleIntervalMinutes
watchEnabled = job.watchEnabled; watchPath = job.watchPath; watchDebounceSeconds = job.watchDebounceSeconds
allowUnattendedDelete = job.allowUnattendedDelete; retryCount = job.retryCount; retryDelaySeconds = job.retryDelaySeconds
notifyOnFailure = job.notifyOnFailure; notifyOnSuccess = job.notifyOnSuccess
requireExpectedVolume = job.requireExpectedVolume; expectedVolumeIdentifier = job.expectedVolumeIdentifier; minimumFreeSpaceMB = job.minimumFreeSpaceMB
}
func makeJob() -> SyncJob {
let job = SyncJob(name: name, sourcePath: sourcePath, destinationPath: destinationPath)
job.isRemote = isRemote; job.remoteHost = remoteHost; job.remoteUser = remoteUser; job.remotePort = remotePort; job.sshKeyPath = ""
job.archive = archive; job.compress = compress; job.deleteExtraneous = deleteExtraneous; job.preservePermissions = preservePermissions
job.preserveExtendedAttributes = preserveExtendedAttributes; job.showProgress = showProgress; job.exclusionsText = exclusionsText; job.additionalArguments = additionalArguments
job.isEnabled = isRemote ? false : isEnabled
job.scheduleEnabled = isRemote ? false : scheduleEnabled; job.scheduleKindRaw = scheduleKindRaw; job.scheduleStartAt = scheduleStartAt; job.scheduleIntervalMinutes = scheduleIntervalMinutes
job.nextRunAt = job.scheduleEnabled ? job.calculateNextRun(after: Date().addingTimeInterval(-1)) : nil
job.watchEnabled = isRemote ? false : watchEnabled; job.watchPath = watchPath; job.watchDebounceSeconds = watchDebounceSeconds
job.allowUnattendedDelete = allowUnattendedDelete; job.retryCount = retryCount; job.retryDelaySeconds = retryDelaySeconds
job.notifyOnFailure = notifyOnFailure; job.notifyOnSuccess = notifyOnSuccess; job.requireExpectedVolume = requireExpectedVolume
job.expectedVolumeIdentifier = expectedVolumeIdentifier; job.minimumFreeSpaceMB = minimumFreeSpaceMB
return job
}
}
private enum JobTransferError: LocalizedError {
case unsupportedVersion
var errorDescription: String? { "This job configuration file uses an unsupported schema version." }
}