import SwiftUI import SwiftData import AppKit struct JobDetailView: View { @Environment(\.modelContext) private var modelContext @EnvironmentObject private var runner: RsyncRunner let job: SyncJob @State private var showingEditor = false @State private var editorSection: JobEditorSection? @State private var errorMessage: String? @State private var showingDeleteWarning = false @State private var showingRunSheet = false @State private var presentedRun: SyncRun? var body: some View { VStack(spacing: 0) { jobHeader Divider() ScrollView { VStack(alignment: .leading, spacing: 18) { locationsSection automationSection optionsSection if job.deleteExtraneous { Label("Delete mode is enabled. Destination files that are not present at the source can be removed.", systemImage: "exclamationmark.triangle.fill") .font(.callout) .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } HStack(spacing: 10) { Button { launch(dryRun: true, presentation: .manual) } label: { Label("Dry Run", systemImage: "doc.text.magnifyingglass") } .disabled(runner.isRunning || !job.isEnabled) Button { if job.deleteExtraneous { showingDeleteWarning = true } else { launch(dryRun: false, presentation: .manual) } } label: { Label("Run Now", systemImage: "play.fill") } .buttonStyle(.borderedProminent) .disabled(runner.isRunning || !job.isEnabled) if runner.isRunning && runner.currentJobID == job.id { AnimatedSyncIndicator(size: 18, lineWidth: 2.6) .help("Synchronising this job") } Spacer() } } .padding(24) .frame(maxWidth: 1050, alignment: .leading) .frame(maxWidth: .infinity, alignment: .center) } } .sheet(isPresented: $showingEditor) { JobEditorView(job: job, initialSection: editorSection) } .sheet(isPresented: $showingRunSheet) { if let presentedRun { RunProgressSheet(run: presentedRun, job: job) .environmentObject(runner) } } .alert("Run with --delete?", isPresented: $showingDeleteWarning) { Button("Cancel", role: .cancel) {} Button("Run", role: .destructive) { launch(dryRun: false, presentation: .manual) } } message: { Text("This job may delete files from the destination. Consider running a Dry Run first.") } .alert("Rsync Error", isPresented: Binding( get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } } )) { Button("OK", role: .cancel) { errorMessage = nil } } message: { Text(errorMessage ?? "Unknown error") } } private var jobHeader: some View { HStack(spacing: 16) { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Text(job.name) .font(.title2.bold()) .lineLimit(1) .truncationMode(.tail) if !job.isEnabled { Text("Disabled") .font(.caption.weight(.semibold)) .foregroundStyle(Color.orange) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color.orange.opacity(0.10), in: Capsule()) } } Label(job.isRemote ? "SSH remote sync" : "Local sync", systemImage: job.isRemote ? "network" : "internaldrive") .font(.callout) .foregroundStyle(.secondary) } Spacer(minLength: 16) Button { openEditor(nil) } label: { Label("Edit Job", systemImage: "pencil") } .buttonStyle(.borderedProminent) .keyboardShortcut("e", modifiers: [.command]) } .padding(.horizontal, 24) .frame(height: 86) .background(Color(nsColor: .controlBackgroundColor)) } private var locationsSection: some View { GroupBox("Locations") { Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 10) { GridRow { Text("Source").foregroundStyle(.secondary) Text(job.sourcePath).textSelection(.enabled) } GridRow { Text("Destination").foregroundStyle(.secondary) Text(job.destinationDisplay).textSelection(.enabled) } if job.isRemote { GridRow { Text("SSH").foregroundStyle(.secondary) Text("Port \(job.remotePort)") } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 3) } } private var automationSection: some View { GroupBox("Automation") { VStack(spacing: 0) { automationRow( title: "Schedule", subtitle: job.scheduleEnabled ? job.scheduleDescription : "Manual only", icon: "calendar.badge.clock", colour: .blue, isOn: scheduleBinding, buttonTitle: job.scheduleEnabled ? "Edit" : "Configure" ) { openEditor(.schedule) } Divider().padding(.leading, 46) automationRow( title: "File Watch", subtitle: watchSubtitle, icon: "eye.fill", colour: .purple, isOn: watchBinding, buttonTitle: job.watchEnabled ? "Edit" : "Configure" ) { openEditor(.fileWatch) } } .padding(.vertical, 2) } } private func automationRow( title: String, subtitle: String, icon: String, colour: Color, isOn: Binding, buttonTitle: String, action: @escaping () -> Void ) -> some View { HStack(spacing: 12) { ZStack { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(colour.opacity(0.12)) Image(systemName: icon) .foregroundStyle(colour) } .frame(width: 34, height: 34) VStack(alignment: .leading, spacing: 2) { Text(title).font(.body.weight(.semibold)) Text(subtitle) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } Spacer(minLength: 12) Toggle("", isOn: isOn) .labelsHidden() .toggleStyle(.switch) Button(buttonTitle, action: action) .buttonStyle(.bordered) .controlSize(.small) .frame(width: 88) } .padding(.vertical, 10) } private var watchSubtitle: String { guard job.watchEnabled else { return "Off" } let path = job.effectiveWatchPath return "Watching \(path) · \(job.watchDebounceSeconds)s debounce" } private var optionsSection: some View { GroupBox("Options") { FlowLayout(spacing: 8) { option("Archive", job.archive) option("Compress", job.compress) option("Delete", job.deleteExtraneous) option("Permissions", job.preservePermissions) option("Extended Attributes", job.preserveExtendedAttributes) option("Progress", job.showProgress) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 3) } } private var scheduleBinding: Binding { Binding( get: { job.scheduleEnabled }, set: { enabled in if enabled && job.deleteExtraneous && !job.allowUnattendedDelete { errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on scheduling." return } job.scheduleEnabled = enabled if enabled { if job.scheduleKind == .manual { job.scheduleKind = .daily } if job.scheduleStartAt < Date() { job.scheduleStartAt = Date().addingTimeInterval(3600) } job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1)) } else { job.nextRunAt = nil } persistAutomationChange() } ) } private var watchBinding: Binding { Binding( get: { job.watchEnabled }, set: { enabled in if enabled && job.deleteExtraneous && !job.allowUnattendedDelete { errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on file watching." return } job.watchEnabled = enabled if enabled && job.watchPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { job.watchPath = job.sourcePath } persistAutomationChange() } ) } private func openEditor(_ section: JobEditorSection?) { editorSection = section showingEditor = true } private func persistAutomationChange() { job.updatedAt = Date() do { try modelContext.save() NotificationCenter.default.post(name: .syncJobsChanged, object: nil) } catch { errorMessage = "Could not save automation settings: \(error.localizedDescription)" } } @ViewBuilder private func option(_ name: String, _ enabled: Bool) -> some View { if enabled { Label(name, systemImage: "checkmark") .font(.caption.weight(.medium)) .padding(.horizontal, 9) .padding(.vertical, 5) .background(.quaternary, in: Capsule()) } } private enum RunPresentation { case manual case scheduled var showsModal: Bool { switch self { case .manual: return true case .scheduled: return false } } } private func launch(dryRun: Bool, presentation: RunPresentation) { let executable = RsyncLocator.locate() let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: dryRun, executable: executable) let run = SyncRun(job: job, command: command, wasDryRun: dryRun, trigger: .manual) modelContext.insert(run) do { try modelContext.save() } catch { errorMessage = "Could not create run history: \(error.localizedDescription)" return } if presentation.showsModal { presentedRun = run showingRunSheet = true } Task { do { let result = try await runner.run(job: job, dryRun: dryRun, trigger: .manual) run.output = SyncRun.sanitizedLog(result.stdout) run.errorOutput = SyncRun.sanitizedLog(result.stderr) run.exitCode = Int(result.exitCode) run.wasCancelled = result.wasCancelled run.finishedAt = Date() job.lastRunAt = run.finishedAt job.lastExitCode = Int(result.exitCode) job.updatedAt = Date() do { try modelContext.save() } catch { errorMessage = "Could not save run history: \(error.localizedDescription)" } } catch { run.errorOutput = SyncRun.sanitizedLog(error.localizedDescription) run.exitCode = -1 run.finishedAt = Date() job.lastRunAt = run.finishedAt job.lastExitCode = -1 do { try modelContext.save() } catch { errorMessage = "Could not save failed run history: \(error.localizedDescription)" } if !presentation.showsModal { errorMessage = error.localizedDescription } } } } } private struct RunProgressSheet: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var runner: RsyncRunner @Bindable var run: SyncRun let job: SyncJob @State private var exportError: String? @State private var showTechnicalDetails = false @State private var now = Date() private var isRunning: Bool { run.finishedAt == nil } var body: some View { VStack(spacing: 0) { header Divider() Group { if isRunning { runningContent } else { completedContent } } } .frame(width: 680) .frame(minHeight: 520) .background(Color(nsColor: .windowBackgroundColor)) .interactiveDismissDisabled(isRunning) .task { while isRunning { try? await Task.sleep(for: .seconds(1)) now = Date() } } .alert("Could Not Export Log", isPresented: Binding( get: { exportError != nil }, set: { if !$0 { exportError = nil } } )) { Button("OK", role: .cancel) { exportError = nil } } message: { Text(exportError ?? "Unknown error") } } private var header: some View { HStack(spacing: 14) { ZStack { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color.accentColor.opacity(0.12)) .frame(width: 44, height: 44) Image(systemName: run.wasDryRun ? "doc.text.magnifyingglass" : "arrow.triangle.2.circlepath") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(Color.accentColor) } VStack(alignment: .leading, spacing: 3) { Text(run.wasDryRun ? "Dry Run" : "Synchronisation") .font(.title2.bold()) Text(run.jobName) .foregroundStyle(.secondary) } Spacer() if !isRunning { Button("Done") { dismiss() } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) } } .padding(22) .background(.bar) } private var runningContent: some View { VStack(spacing: 26) { Spacer(minLength: 26) AnimatedSyncIndicator(size: 124, lineWidth: 9) .padding(.bottom, 2) VStack(spacing: 8) { Text(run.wasDryRun ? "Checking what would change…" : "Synchronising files…") .font(.title3.bold()) Text(run.wasDryRun ? "Rsync is comparing the source and destination. No files will be changed." : "Rsync is scanning and transferring changed files. Full output is being captured without slowing the interface.") .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 500) } connectionCard HStack(spacing: 22) { Label("Elapsed \(elapsedText)", systemImage: "timer") Label("Log captured", systemImage: "doc.text") } .font(.caption.weight(.medium)) .foregroundStyle(.secondary) Spacer() HStack { Text("You can continue using the app while this transfer runs.") .font(.caption) .foregroundStyle(.secondary) Spacer() if runner.currentJobID == run.jobID { Button("Cancel", role: .destructive) { runner.cancel() } .buttonStyle(.bordered) .controlSize(.large) } } .padding(22) .background(.bar) } } private var connectionCard: some View { HStack(spacing: 16) { VStack(alignment: .leading, spacing: 5) { Label("Source", systemImage: "folder.fill") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) Text(job.sourcePath) .font(.system(.body, design: .monospaced)) .lineLimit(2) .truncationMode(.middle) } .frame(maxWidth: .infinity, alignment: .leading) Image(systemName: "arrow.right") .font(.title3.weight(.semibold)) .foregroundStyle(Color.accentColor) VStack(alignment: .leading, spacing: 5) { Label("Destination", systemImage: job.isRemote ? "server.rack" : "externaldrive.fill") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) Text(job.destinationDisplay) .font(.system(.body, design: .monospaced)) .lineLimit(2) .truncationMode(.middle) } .frame(maxWidth: .infinity, alignment: .leading) } .padding(16) .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .padding(.horizontal, 28) } private var completedContent: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { completionHero if run.summary.hasStatistics { HStack(spacing: 12) { metricCard(title: run.wasDryRun ? "Would transfer" : "Transferred", value: run.summary.numberOfFilesTransferred ?? "—", icon: "doc.on.doc.fill") metricCard(title: "Data", value: run.summary.totalTransferredFileSize ?? "—", icon: "arrow.down.circle.fill") metricCard(title: "Files checked", value: run.summary.numberOfFiles ?? "—", icon: "checklist") } GroupBox("Transfer Summary") { Grid(alignment: .leading, horizontalSpacing: 34, verticalSpacing: 10) { summaryRow("Files checked", run.summary.numberOfFiles) summaryRow(run.wasDryRun ? "Files that would transfer" : "Files transferred", run.summary.numberOfFilesTransferred) summaryRow("Total source size", run.summary.totalFileSize) summaryRow(run.wasDryRun ? "Data that would transfer" : "Data transferred", run.summary.totalTransferredFileSize) summaryRow("Unmatched data", run.summary.unmatchedData) summaryRow("Matched data", run.summary.matchedData) summaryRow("File list size", run.summary.fileListSize) summaryRow("List generation", run.summary.fileListGenerationTime) summaryRow("List transfer", run.summary.fileListTransferTime) summaryRow("Sent", run.summary.totalSent) summaryRow("Received", run.summary.totalReceived) } .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) } if run.summary.transferRate != nil || run.summary.totalSizeAndSpeedup != nil { VStack(alignment: .leading, spacing: 6) { if let transferRate = run.summary.transferRate { Text(transferRate) } if let speedup = run.summary.totalSizeAndSpeedup { Text(speedup) } } .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) .textSelection(.enabled) } } if !run.succeeded { DisclosureGroup("Technical Details", isExpanded: $showTechnicalDetails) { Text(run.errorOutput.isEmpty ? "Rsync exited without additional error output." : run.errorOutput) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 8) } .padding(14) .background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) } Divider() HStack { Button { exportLog() } label: { Label("Export Log…", systemImage: "square.and.arrow.up") } .controlSize(.large) Spacer() if let duration = run.duration { Label(Duration.seconds(duration).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated)), systemImage: "timer") .foregroundStyle(.secondary) } Button("Done") { dismiss() } .buttonStyle(.borderedProminent) .controlSize(.large) } } .padding(24) } } private var completionHero: some View { HStack(spacing: 16) { Image(systemName: completionIcon) .font(.system(size: 42, weight: .semibold)) .foregroundStyle(completionColour) VStack(alignment: .leading, spacing: 4) { Text(completionTitle) .font(.title2.bold()) Text(completionMessage) .foregroundStyle(.secondary) if let exitCode = run.exitCode, !run.succeeded { Text("Rsync exit code \(exitCode)") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) } } } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .background(completionColour.opacity(0.09), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } private func metricCard(title: String, value: String, icon: String) -> some View { VStack(alignment: .leading, spacing: 8) { Image(systemName: icon) .foregroundStyle(Color.accentColor) Text(value) .font(.title3.bold()) .lineLimit(1) Text(title) .font(.caption) .foregroundStyle(.secondary) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) } private var completionIcon: String { if run.wasCancelled { return "xmark.circle.fill" } return run.succeeded ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" } private var completionColour: Color { if run.wasCancelled { return .secondary } return run.succeeded ? .green : .orange } private var completionTitle: String { if run.wasCancelled { return "Synchronisation Cancelled" } if run.succeeded { return run.wasDryRun ? "Dry Run Complete" : "Synchronisation Complete" } return "Synchronisation Failed" } private var completionMessage: String { if run.wasCancelled { return "The transfer was stopped before it completed." } if run.succeeded && run.wasDryRun { return "No files were changed. The summary below shows what a real run would transfer." } if run.succeeded { return "Your files were synchronised successfully." } if !run.errorOutput.isEmpty { return run.errorOutput.components(separatedBy: .newlines).first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) ?? "Rsync reported an error." } return "Rsync could not complete this synchronisation." } private var elapsedText: String { let interval = max(0, now.timeIntervalSince(run.startedAt)) return Duration.seconds(interval).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated)) } @ViewBuilder private func summaryRow(_ title: String, _ value: String?) -> some View { if let value { GridRow { Text(title).foregroundStyle(.secondary) Text(value).fontWeight(.medium).textSelection(.enabled) } } } private func exportLog() { let panel = NSSavePanel() panel.title = "Export Rsync Log" panel.prompt = "Export" panel.canCreateDirectories = true panel.nameFieldStringValue = defaultLogFilename guard panel.runModal() == .OK, let url = panel.url else { return } do { try run.exportText.write(to: url, atomically: true, encoding: .utf8) } catch { exportError = error.localizedDescription } } private var defaultLogFilename: String { let safeName = run.jobName.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-") let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" return "\(safeName)-\(formatter.string(from: run.startedAt)).log" } }