690 lines
27 KiB
Swift
690 lines
27 KiB
Swift
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<Bool>,
|
|
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<Bool> {
|
|
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<Bool> {
|
|
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"
|
|
}
|
|
}
|