Initial Commit

This commit is contained in:
Mark Gregory
2026-09-08 14:12:59 +01:00
commit 3b95f53a25
46 changed files with 4334 additions and 0 deletions
@@ -0,0 +1,36 @@
import SwiftUI
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let width = proposal.width ?? .infinity
var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0
for view in subviews {
let size = view.sizeThatFits(.unspecified)
if x + size.width > width, x > 0 {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
return CGSize(width: width.isFinite ? width : x, height: y + rowHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0
for view in subviews {
let size = view.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX, x > bounds.minX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
view.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
@@ -0,0 +1,22 @@
import SwiftUI
struct PathField: View {
let title: String
@Binding var path: String
var canBrowse: Bool = true
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title).font(.headline)
HStack {
TextField(title, text: $path)
.textFieldStyle(.roundedBorder)
if canBrowse {
Button("Choose…") {
if let selected = FilePicker.chooseDirectory() { path = selected }
}
}
}
}
}
}
@@ -0,0 +1,49 @@
import SwiftUI
struct SplashView: View {
@State private var animate = false
var body: some View {
ZStack {
LinearGradient(
colors: [Color.accentColor.opacity(0.95), Color.accentColor.opacity(0.62), Color(nsColor: .windowBackgroundColor)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(spacing: 22) {
ZStack {
RoundedRectangle(cornerRadius: 30, style: .continuous)
.fill(.ultraThinMaterial)
.frame(width: 126, height: 126)
.shadow(color: .black.opacity(0.18), radius: 28, y: 14)
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 58, weight: .semibold))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.white)
.rotationEffect(.degrees(animate ? 360 : 0))
}
VStack(spacing: 6) {
Text("Rsync Manager")
.font(.system(size: 32, weight: .bold, design: .rounded))
Text("Fast, dependable file synchronisation for macOS")
.font(.headline)
.foregroundStyle(.secondary)
}
ProgressView()
.controlSize(.small)
.padding(.top, 2)
}
.padding(48)
}
.onAppear {
withAnimation(.linear(duration: 1.25).repeatForever(autoreverses: false)) {
animate = true
}
}
}
}
@@ -0,0 +1,25 @@
import SwiftUI
struct StatusBadge: View {
let run: SyncRun
var body: some View {
Label(text, systemImage: icon)
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary, in: Capsule())
}
private var text: String {
if run.isRunning { return "Running" }
if run.wasCancelled { return "Cancelled" }
return run.succeeded ? "Success" : "Failed"
}
private var icon: String {
if run.isRunning { return "arrow.triangle.2.circlepath" }
if run.wasCancelled { return "stop.circle" }
return run.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill"
}
}
+220
View File
@@ -0,0 +1,220 @@
import SwiftUI
struct HelpView: View {
@Environment(\.dismiss) private var dismiss
@State private var blocks: [HelpBlock] = [.paragraph("Loading help…")]
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Image(systemName: "questionmark.circle.fill")
.font(.title2)
.foregroundStyle(Color.accentColor)
VStack(alignment: .leading, spacing: 1) {
Text("Rsync Manager Help")
.font(.headline)
Text("Instructions, examples and troubleshooting")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.cancelAction)
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(.bar)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 14) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
blockView(block)
}
}
.textSelection(.enabled)
.frame(maxWidth: 900, alignment: .leading)
.padding(.horizontal, 34)
.padding(.vertical, 28)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.frame(minWidth: 780, idealWidth: 940, minHeight: 640, idealHeight: 780)
.task { loadHelp() }
}
@ViewBuilder
private func blockView(_ block: HelpBlock) -> some View {
switch block {
case .heading(let level, let text):
Text(text)
.font(headingFont(level))
.padding(.top, level == 1 ? 4 : 10)
.frame(maxWidth: .infinity, alignment: .leading)
case .paragraph(let text):
Text(markdown(text))
.font(.body)
.lineSpacing(4)
.frame(maxWidth: .infinity, alignment: .leading)
case .bullet(let text):
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("•")
.font(.body.bold())
Text(markdown(text))
.font(.body)
.lineSpacing(3)
}
.padding(.leading, 6)
case .numbered(let number, let text):
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("\(number).")
.font(.body.weight(.semibold))
.frame(width: 28, alignment: .trailing)
Text(markdown(text))
.font(.body)
.lineSpacing(3)
}
case .code(let text):
ScrollView(.horizontal) {
Text(text)
.font(.system(.body, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
}
.background(Color(nsColor: .textBackgroundColor).opacity(0.55), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(.quaternary, lineWidth: 1)
}
}
}
private func headingFont(_ level: Int) -> Font {
switch level {
case 1: return .largeTitle.bold()
case 2: return .title2.bold()
case 3: return .title3.weight(.semibold)
default: return .headline
}
}
private func markdown(_ text: String) -> AttributedString {
(try? AttributedString(
markdown: text,
options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
)) ?? AttributedString(text)
}
private func loadHelp() {
guard let url = Bundle.main.url(forResource: "RsyncManagerHelp", withExtension: "md"),
let text = try? String(contentsOf: url, encoding: .utf8) else {
blocks = [.heading(1, "Help unavailable"), .paragraph("The bundled help document could not be loaded.")]
return
}
blocks = HelpMarkdownParser.parse(text)
}
}
private enum HelpBlock {
case heading(Int, String)
case paragraph(String)
case bullet(String)
case numbered(Int, String)
case code(String)
}
private enum HelpMarkdownParser {
static func parse(_ source: String) -> [HelpBlock] {
var result: [HelpBlock] = []
var paragraph: [String] = []
var codeLines: [String] = []
var insideCode = false
func flushParagraph() {
guard !paragraph.isEmpty else { return }
result.append(.paragraph(paragraph.joined(separator: " ")))
paragraph.removeAll(keepingCapacity: true)
}
func flushCode() {
guard !codeLines.isEmpty else { return }
result.append(.code(codeLines.joined(separator: "\n")))
codeLines.removeAll(keepingCapacity: true)
}
for rawLine in source.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("```") {
if insideCode {
flushCode()
insideCode = false
} else {
flushParagraph()
insideCode = true
}
continue
}
if insideCode {
codeLines.append(rawLine)
continue
}
if line.isEmpty {
flushParagraph()
continue
}
if let heading = heading(from: line) {
flushParagraph()
result.append(.heading(heading.level, heading.text))
continue
}
if line.hasPrefix("- ") || line.hasPrefix("* ") {
flushParagraph()
result.append(.bullet(String(line.dropFirst(2))))
continue
}
if let numbered = numberedItem(from: line) {
flushParagraph()
result.append(.numbered(numbered.number, numbered.text))
continue
}
paragraph.append(line)
}
flushParagraph()
flushCode()
return result
}
private static func heading(from line: String) -> (level: Int, text: String)? {
let hashes = line.prefix { $0 == "#" }.count
guard hashes > 0, hashes <= 6 else { return nil }
let remainder = line.dropFirst(hashes)
guard remainder.first == " " else { return nil }
return (hashes, String(remainder.dropFirst()))
}
private static func numberedItem(from line: String) -> (number: Int, text: String)? {
guard let dot = line.firstIndex(of: ".") else { return nil }
let numberText = line[..<dot]
guard let number = Int(numberText) else { return nil }
let afterDot = line.index(after: dot)
guard afterDot < line.endIndex, line[afterDot] == " " else { return nil }
let textStart = line.index(after: afterDot)
return (number, String(line[textStart...]))
}
}
+670
View File
@@ -0,0 +1,670 @@
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
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
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()
try? modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
}
@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)
try? modelContext.save()
if presentation.showsModal {
presentedRun = run
showingRunSheet = true
}
Task {
do {
let result = try await runner.run(job: job, dryRun: dryRun)
run.output = result.stdout
run.errorOutput = 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()
try? modelContext.save()
} catch {
run.errorOutput = error.localizedDescription
run.exitCode = -1
run.finishedAt = Date()
job.lastRunAt = run.finishedAt
job.lastExitCode = -1
try? modelContext.save()
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"
}
}
+271
View File
@@ -0,0 +1,271 @@
import SwiftUI
import SwiftData
enum JobEditorSection: Hashable {
case schedule
case fileWatch
}
struct JobEditorView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@AppStorage("defaultSSHUsername") private var defaultSSHUsername = NSUserName()
let existingJob: SyncJob?
let initialSection: JobEditorSection?
@State private var name: String
@State private var sourcePath: String
@State private var destinationPath: String
@State private var isRemote: Bool
@State private var remoteHost: String
@State private var remoteUser: String
@State private var remotePort: Int
@State private var sshKeyPath: String
@State private var archive: Bool
@State private var compress: Bool
@State private var deleteExtraneous: Bool
@State private var preservePermissions: Bool
@State private var preserveExtendedAttributes: Bool
@State private var showProgress: Bool
@State private var exclusionsText: String
@State private var additionalArguments: String
@State private var isEnabled: Bool
@State private var scheduleEnabled: Bool
@State private var scheduleKind: SyncScheduleKind
@State private var scheduleStartAt: Date
@State private var scheduleIntervalMinutes: Int
@State private var watchEnabled: Bool
@State private var watchPath: String
@State private var watchDebounceSeconds: Int
init(job: SyncJob? = nil, initialSection: JobEditorSection? = nil) {
self.existingJob = job
self.initialSection = initialSection
_name = State(initialValue: job?.name ?? "New Sync Job")
_sourcePath = State(initialValue: job?.sourcePath ?? "")
_destinationPath = State(initialValue: job?.destinationPath ?? "")
_isRemote = State(initialValue: job?.isRemote ?? false)
_remoteHost = State(initialValue: job?.remoteHost ?? UserDefaults.standard.string(forKey: "defaultRemoteHost") ?? "")
_remoteUser = State(initialValue: job?.remoteUser ?? UserDefaults.standard.string(forKey: "defaultSSHUsername") ?? NSUserName())
let savedPort = UserDefaults.standard.integer(forKey: "defaultSSHPort")
_remotePort = State(initialValue: job?.remotePort ?? (savedPort == 0 ? 22 : savedPort))
_sshKeyPath = State(initialValue: job?.sshKeyPath ?? UserDefaults.standard.string(forKey: "defaultSSHKeyPath") ?? "")
_archive = State(initialValue: job?.archive ?? true)
_compress = State(initialValue: job?.compress ?? false)
_deleteExtraneous = State(initialValue: job?.deleteExtraneous ?? false)
_preservePermissions = State(initialValue: job?.preservePermissions ?? true)
_preserveExtendedAttributes = State(initialValue: job?.preserveExtendedAttributes ?? true)
_showProgress = State(initialValue: job?.showProgress ?? true)
_exclusionsText = State(initialValue: job?.exclusionsText ?? "")
_additionalArguments = State(initialValue: job?.additionalArguments ?? "")
_isEnabled = State(initialValue: job?.isEnabled ?? true)
_scheduleEnabled = State(initialValue: job?.scheduleEnabled ?? false)
_scheduleKind = State(initialValue: job?.scheduleKind ?? .daily)
_scheduleStartAt = State(initialValue: job?.scheduleStartAt ?? Date().addingTimeInterval(3600))
_scheduleIntervalMinutes = State(initialValue: job?.scheduleIntervalMinutes ?? 30)
_watchEnabled = State(initialValue: job?.watchEnabled ?? false)
_watchPath = State(initialValue: job?.watchPath ?? "")
let defaultDebounce = UserDefaults.standard.integer(forKey: "defaultWatchDebounceSeconds")
_watchDebounceSeconds = State(initialValue: job?.watchDebounceSeconds ?? (defaultDebounce == 0 ? 3 : defaultDebounce))
}
var body: some View {
NavigationStack {
ScrollViewReader { proxy in
Form {
Section("Job") {
TextField("Name", text: $name)
Toggle("Enabled", isOn: $isEnabled)
}
Section("Locations") {
PathField(title: "Source", path: $sourcePath)
Picker("Destination type", selection: $isRemote) {
Text("Local / Mounted Volume").tag(false)
Text("SSH / Remote Mac or Server").tag(true)
}
.pickerStyle(.segmented)
if isRemote {
TextField("Remote path", text: $destinationPath, prompt: Text("/home/mag/backup or ~/backup"))
.textFieldStyle(.roundedBorder)
} else {
PathField(title: "Destination", path: $destinationPath)
}
}
if isRemote {
Section("SSH") {
TextField("Host", text: $remoteHost, prompt: Text("pegasus.local"))
TextField("User", text: $remoteUser, prompt: Text(defaultSSHUsername.isEmpty ? NSUserName() : defaultSSHUsername))
Stepper("Port: \(remotePort)", value: $remotePort, in: 1...65535)
HStack {
TextField("Private SSH key", text: $sshKeyPath, prompt: Text("~/.ssh/id_ed25519 or ~/.ssh/id_rsa"))
Button("Choose…") {
if let selected = FilePicker.chooseFile() { sshKeyPath = selected }
}
}
if sshKeyPath.hasSuffix(".pub") {
Label("Select the private key, not the .pub public key.", systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
}
}
Section {
Toggle("Run this job on a schedule", isOn: $scheduleEnabled)
if scheduleEnabled {
Picker("Frequency", selection: $scheduleKind) {
ForEach(SyncScheduleKind.allCases.filter { $0 != .manual }) { kind in
Label(kind.title, systemImage: kind.systemImage).tag(kind)
}
}
DatePicker(
scheduleKind == .once ? "Run date and time" : "Starting",
selection: $scheduleStartAt,
displayedComponents: scheduleKind == .once ? [.date, .hourAndMinute] : [.date, .hourAndMinute]
)
if scheduleKind == .custom {
Stepper("Every \(scheduleIntervalMinutes) minutes", value: $scheduleIntervalMinutes, in: 1...525600)
}
Label(schedulePreview, systemImage: "calendar.badge.clock")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Schedule")
} footer: {
Text("Scheduled jobs run silently and are recorded in History. They continue while Rsync Manager is running, even when its window is in the background.")
}
.id(JobEditorSection.schedule)
Section {
Toggle("Watch for file changes", isOn: $watchEnabled)
if watchEnabled {
HStack {
TextField("Watch path", text: $watchPath, prompt: Text(sourcePath.isEmpty ? "Uses the job source" : sourcePath))
Menu("Choose…") {
Button("Choose Folder…") {
if let selected = FilePicker.chooseDirectory() { watchPath = selected }
}
Button("Choose File…") {
if let selected = FilePicker.chooseFile() { watchPath = selected }
}
}
}
Stepper("Wait \(watchDebounceSeconds) seconds after changes", value: $watchDebounceSeconds, in: 1...60)
Label("Changes are monitored with macOS FSEvents. Bursts of edits are combined into one sync.", systemImage: "waveform.path.ecg")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("File Watching")
} footer: {
Text("Leave the watch path empty to watch the source. If the source is a single file, only that file is synced; if it is a folder, the configured folder job runs when anything inside changes.")
}
.id(JobEditorSection.fileWatch)
Section("Rsync Options") {
Toggle("Archive mode (-a)", isOn: $archive)
Toggle("Compress during transfer (-z)", isOn: $compress)
Toggle("Delete files missing from source (--delete)", isOn: $deleteExtraneous)
Toggle("Preserve permissions", isOn: $preservePermissions)
Toggle("Preserve extended attributes", isOn: $preserveExtendedAttributes)
Toggle("Show transfer progress", isOn: $showProgress)
}
Section("Exclusions") {
TextEditor(text: $exclusionsText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 90)
Text("One exclusion pattern per line.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Advanced") {
TextField("Additional rsync arguments", text: $additionalArguments)
.font(.system(.body, design: .monospaced))
}
}
.formStyle(.grouped)
.navigationTitle(existingJob == nil ? "New Sync Job" : "Edit Sync Job")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(!canSave)
}
}
.task {
guard let initialSection else { return }
try? await Task.sleep(for: .milliseconds(120))
withAnimation { proxy.scrollTo(initialSection, anchor: .top) }
}
}
}
.frame(width: 690, height: 820)
}
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!sourcePath.isEmpty &&
!destinationPath.isEmpty &&
(!isRemote || !remoteHost.isEmpty)
}
private var schedulePreview: String {
let previewJob = SyncJob()
previewJob.scheduleEnabled = scheduleEnabled
previewJob.scheduleKind = scheduleKind
previewJob.scheduleStartAt = scheduleStartAt
previewJob.scheduleIntervalMinutes = scheduleIntervalMinutes
if let next = previewJob.calculateNextRun() {
return "Next run: \(next.formatted(date: .abbreviated, time: .shortened))"
}
return "No future run is currently scheduled."
}
private func save() {
let job = existingJob ?? SyncJob()
job.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
job.sourcePath = sourcePath
job.destinationPath = destinationPath
job.isRemote = isRemote
job.remoteHost = remoteHost
job.remoteUser = remoteUser
job.remotePort = remotePort
job.sshKeyPath = 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 = isEnabled
job.scheduleEnabled = scheduleEnabled
job.scheduleKind = scheduleEnabled ? scheduleKind : .manual
job.scheduleStartAt = scheduleStartAt
job.scheduleIntervalMinutes = scheduleIntervalMinutes
job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1))
job.watchEnabled = watchEnabled
job.watchPath = watchPath
job.watchDebounceSeconds = watchDebounceSeconds
job.updatedAt = Date()
if existingJob == nil { modelContext.insert(job) }
try? modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
dismiss()
}
}
+352
View File
@@ -0,0 +1,352 @@
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
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)
}
}
}
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
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() {
try? modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
}
}
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"
}
}
}
@@ -0,0 +1,119 @@
import SwiftUI
import SwiftData
struct RunHistoryView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun]
@State private var selection: SyncRun?
let onBack: () -> Void
var body: some View {
HSplitView {
VStack(spacing: 0) {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text("Recent Runs")
.font(.headline)
Text("\(runs.count) recorded")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Clear", role: .destructive) {
runs.forEach(modelContext.delete)
selection = nil
try? modelContext.save()
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(runs.isEmpty)
}
.padding(14)
Divider()
if runs.isEmpty {
ContentUnavailableView(
"No Run History",
systemImage: "clock.arrow.circlepath",
description: Text("Completed, failed and background synchronisations will appear here.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(selection: $selection) {
ForEach(runs) { run in
HStack(spacing: 11) {
Image(systemName: statusIcon(run))
.foregroundStyle(statusColour(run))
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(run.jobName)
.fontWeight(.semibold)
.lineLimit(1)
Spacer()
StatusBadge(run: run)
}
Text(run.startedAt, format: .dateTime.day().month(.abbreviated).year().hour().minute())
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 6) {
Label(run.trigger.title, systemImage: run.trigger.systemImage)
.font(.caption2.weight(.semibold))
.foregroundStyle(run.trigger == .watched ? Color.purple : Color.blue)
if run.wasDryRun {
Text("Dry run")
.font(.caption2.weight(.semibold))
.foregroundStyle(.blue)
}
}
}
}
.padding(.vertical, 3)
.tag(run)
.contextMenu {
Button("Delete Log", role: .destructive) {
if selection?.id == run.id { selection = nil }
modelContext.delete(run)
try? modelContext.save()
}
}
}
}
.listStyle(.sidebar)
}
}
.frame(minWidth: 300, idealWidth: 340, maxWidth: 390)
Group {
if let selection {
RunLogView(run: selection)
} else {
ContentUnavailableView(
"Select a Run",
systemImage: "clock.arrow.circlepath",
description: Text("Select a completed or failed run to inspect its summary and full log.")
)
}
}
.frame(minWidth: 620, maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func statusIcon(_ run: SyncRun) -> String {
if run.isRunning { return "arrow.triangle.2.circlepath" }
if run.wasCancelled { return "xmark.circle.fill" }
return run.succeeded ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
}
private func statusColour(_ run: SyncRun) -> Color {
if run.isRunning { return .blue }
if run.wasCancelled { return .secondary }
return run.succeeded ? .green : .orange
}
}
+47
View File
@@ -0,0 +1,47 @@
import SwiftUI
struct RunLogView: View {
let run: SyncRun
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
HStack {
VStack(alignment: .leading) {
Text(run.jobName).font(.title.bold())
Text(run.startedAt.formatted(date: .abbreviated, time: .standard))
.foregroundStyle(.secondary)
}
Spacer()
StatusBadge(run: run)
}
if let duration = run.duration {
LabeledContent("Duration", value: Duration.seconds(duration).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated)))
}
if let exitCode = run.exitCode {
LabeledContent("Exit code", value: String(exitCode))
}
LabeledContent("Mode", value: run.wasDryRun ? "Dry Run" : "Live Run")
console(title: "Command", text: run.command)
if !run.output.isEmpty { console(title: "Output", text: run.output) }
if !run.errorOutput.isEmpty { console(title: "Errors", text: run.errorOutput) }
}
.padding(28)
}
.navigationTitle("Run Log")
}
private func console(title: String, text: String) -> some View {
GroupBox(title) {
ScrollView(.horizontal) {
Text(text)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
@@ -0,0 +1,220 @@
import SwiftUI
import SwiftData
struct ScheduleCalendarView: View {
@Query(sort: \SyncJob.name) private var jobs: [SyncJob]
@State private var displayedMonth = Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: Date())) ?? Date()
@State private var selectedDay = Date()
@State private var editingJob: SyncJob?
private let calendar = Calendar.current
private let columns = Array(repeating: GridItem(.flexible(), spacing: 6), count: 7)
var body: some View {
HStack(spacing: 0) {
VStack(spacing: 0) {
monthHeader
Divider()
calendarGrid
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
agenda
.frame(width: 320)
.background(.bar)
}
.sheet(item: $editingJob) { job in
JobEditorView(job: job)
}
}
private var monthHeader: some View {
HStack {
Button { moveMonth(-1) } label: { Image(systemName: "chevron.left") }
.buttonStyle(.bordered)
VStack(alignment: .leading, spacing: 2) {
Text(displayedMonth.formatted(.dateTime.month(.wide).year()))
.font(.title2.bold())
Text("\(monthOccurrences.count) scheduled runs")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Today") {
displayedMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: Date())) ?? Date()
selectedDay = Date()
}
.buttonStyle(.borderedProminent)
Button { moveMonth(1) } label: { Image(systemName: "chevron.right") }
.buttonStyle(.bordered)
}
.padding(18)
}
private var calendarGrid: some View {
VStack(spacing: 8) {
LazyVGrid(columns: columns, spacing: 6) {
ForEach(calendar.shortWeekdaySymbols, id: \.self) { symbol in
Text(symbol.uppercased())
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
ForEach(monthCells.indices, id: \.self) { index in
if let date = monthCells[index] {
dayCell(date)
} else {
Color.clear.frame(minHeight: 86)
}
}
}
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private func dayCell(_ date: Date) -> some View {
let occurrences = occurrences(on: date)
let selected = calendar.isDate(date, inSameDayAs: selectedDay)
let today = calendar.isDateInToday(date)
return Button {
selectedDay = date
} label: {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("\(calendar.component(.day, from: date))")
.font(.body.weight(today ? .bold : .medium))
.foregroundStyle(today ? Color.accentColor : .primary)
Spacer()
if !occurrences.isEmpty {
Text("\(occurrences.count)")
.font(.caption2.bold())
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor.opacity(0.14), in: Capsule())
}
}
ForEach(Array(occurrences.prefix(2)), id: \.id) { item in
HStack(spacing: 4) {
Circle().fill(Color.accentColor).frame(width: 5, height: 5)
Text(item.job.name)
.font(.caption2)
.lineLimit(1)
Spacer()
Text(item.date.formatted(date: .omitted, time: .shortened))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 0)
}
.padding(9)
.frame(maxWidth: .infinity, minHeight: 86, alignment: .topLeading)
.background(selected ? Color.accentColor.opacity(0.10) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(selected ? Color.accentColor.opacity(0.6) : Color.secondary.opacity(0.12)))
}
.buttonStyle(.plain)
}
private var agenda: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 3) {
Text("Agenda").font(.title3.bold())
Text(selectedDay.formatted(date: .complete, time: .omitted))
.font(.caption)
.foregroundStyle(.secondary)
}
let items = occurrences(on: selectedDay)
if items.isEmpty {
ContentUnavailableView(
"No Scheduled Runs",
systemImage: "calendar",
description: Text("Select another day or add a schedule to a sync job.")
)
} else {
ScrollView {
LazyVStack(spacing: 10) {
ForEach(items, id: \.id) { item in
VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: item.job.isRemote ? "server.rack" : "folder.fill")
.foregroundStyle(Color.accentColor)
Text(item.job.name).fontWeight(.semibold)
Spacer()
}
Label(item.date.formatted(date: .omitted, time: .shortened), systemImage: "clock")
.font(.caption)
.foregroundStyle(.secondary)
Text(item.job.destinationDisplay)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack {
Spacer()
Button("Edit Schedule") { editingJob = item.job }
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.background, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
}
}
Spacer()
}
.padding(18)
}
private var monthInterval: DateInterval {
calendar.dateInterval(of: .month, for: displayedMonth) ?? DateInterval(start: displayedMonth, duration: 2_592_000)
}
private var monthCells: [Date?] {
let interval = monthInterval
let days = calendar.range(of: .day, in: .month, for: interval.start) ?? 1..<2
let weekday = calendar.component(.weekday, from: interval.start)
let leading = (weekday - calendar.firstWeekday + 7) % 7
var cells = Array<Date?>(repeating: nil, count: leading)
for day in days {
if let date = calendar.date(byAdding: .day, value: day - 1, to: interval.start) { cells.append(date) }
}
while cells.count % 7 != 0 { cells.append(nil) }
return cells
}
private var monthOccurrences: [CalendarOccurrence] {
scheduledJobs.flatMap { job in
job.occurrences(from: monthInterval.start, through: monthInterval.end).map { CalendarOccurrence(job: job, date: $0) }
}.sorted { $0.date < $1.date }
}
private var scheduledJobs: [SyncJob] {
jobs.filter { $0.isEnabled && $0.scheduleEnabled && $0.scheduleKind != .manual }
}
private func occurrences(on day: Date) -> [CalendarOccurrence] {
monthOccurrences.filter { calendar.isDate($0.date, inSameDayAs: day) }
}
private func moveMonth(_ offset: Int) {
if let next = calendar.date(byAdding: .month, value: offset, to: displayedMonth) {
displayedMonth = next
selectedDay = next
}
}
}
private struct CalendarOccurrence: Identifiable {
let job: SyncJob
let date: Date
var id: String { "\(job.id.uuidString)-\(date.timeIntervalSince1970)" }
}