Files

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"
}
}