latest updates

This commit is contained in:
Mark Gregory
2026-09-08 20:06:58 +01:00
parent fb05cf71ed
commit 868604b848
53 changed files with 6442 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 Studio")
.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 Studio 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: "Rsync Studio Help", 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...]))
}
}
+772
View File
@@ -0,0 +1,772 @@
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?
@State private var preflightReport: JobPreflightReport?
@State private var isCheckingHealth = false
var body: some View {
VStack(spacing: 0) {
jobHeader
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 18) {
healthSection
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 {
runPreflight()
} label: {
if isCheckingHealth { ProgressView().controlSize(.small) }
else { Label("Test Job", systemImage: "stethoscope") }
}
.disabled(isCheckingHealth || runner.isRunning)
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 healthSection: some View {
GroupBox("Job Health") {
HStack(spacing: 12) {
Image(systemName: healthIcon)
.font(.title3)
.foregroundStyle(healthColour)
VStack(alignment: .leading, spacing: 3) {
Text(preflightReport?.title ?? "Not checked")
.font(.body.weight(.semibold))
Text(healthDetail)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Run Preflight") { runPreflight() }
.buttonStyle(.bordered)
.disabled(isCheckingHealth || runner.isRunning)
}
.padding(.vertical, 3)
if let report = preflightReport {
Divider().padding(.vertical, 6)
VStack(alignment: .leading, spacing: 10) {
ForEach(report.checks) { check in
HStack(alignment: .top, spacing: 10) {
Image(systemName: check.severity == .error ? "xmark.circle.fill" : check.severity == .warning ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(check.severity == .error ? Color.red : check.severity == .warning ? Color.orange : Color.green)
.frame(width: 20, height: 20, alignment: .top)
VStack(alignment: .leading, spacing: 2) {
Text(check.title)
.font(.caption.weight(.semibold))
Text(check.detail)
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private var healthIcon: String {
guard let report = preflightReport else { return "questionmark.circle" }
return report.passed ? "checkmark.shield.fill" : "exclamationmark.shield.fill"
}
private var healthColour: Color {
guard let report = preflightReport else { return .secondary }
return report.passed ? .green : .orange
}
private var healthDetail: String {
guard let report = preflightReport else { return "Run a preflight check before relying on this job unattended." }
return report.passed ? "Source, destination, rsync and connection checks passed." : "One or more checks need attention before running."
}
private func runPreflight() {
isCheckingHealth = true
Task {
let report = await runner.preflight(job: job)
preflightReport = report
isCheckingHealth = false
}
}
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"
}
}
+362
View File
@@ -0,0 +1,362 @@
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
@State private var allowUnattendedDelete: Bool
@State private var retryCount: Int
@State private var retryDelaySeconds: Int
@State private var notifyOnFailure: Bool
@State private var notifyOnSuccess: Bool
@State private var requireExpectedVolume: Bool
@State private var expectedVolumeIdentifier: String
@State private var minimumFreeSpaceMB: Int
@State private var validationMessage: String?
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))
_allowUnattendedDelete = State(initialValue: job?.allowUnattendedDelete ?? false)
_retryCount = State(initialValue: job?.retryCount ?? 2)
_retryDelaySeconds = State(initialValue: job?.retryDelaySeconds ?? 10)
_notifyOnFailure = State(initialValue: job?.notifyOnFailure ?? true)
_notifyOnSuccess = State(initialValue: job?.notifyOnSuccess ?? false)
_requireExpectedVolume = State(initialValue: job?.requireExpectedVolume ?? false)
_expectedVolumeIdentifier = State(initialValue: job?.expectedVolumeIdentifier ?? "")
_minimumFreeSpaceMB = State(initialValue: job?.minimumFreeSpaceMB ?? 512)
_validationMessage = State(initialValue: nil)
}
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("Destination Safety") {
Stepper("Minimum free space: \(minimumFreeSpaceMB) MB", value: $minimumFreeSpaceMB, in: 0...1_048_576, step: 256)
Toggle("Require this exact destination volume", isOn: $requireExpectedVolume)
if requireExpectedVolume {
HStack {
Text(expectedVolumeIdentifier.isEmpty ? "No volume captured" : expectedVolumeIdentifier)
.font(.caption.monospaced())
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Button("Capture Current Volume") {
let expanded = (destinationPath as NSString).expandingTildeInPath
if FileManager.default.fileExists(atPath: expanded), let info = SyncVolumeInspector.info(forPath: expanded) {
expectedVolumeIdentifier = info.identifier
} else {
validationMessage = "Mount and select the actual destination volume before capturing its identity."
}
}
}
Text("Prevents a missing external drive from being replaced by an unintended local folder with the same path.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
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 Studio 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("Reliability & Notifications") {
Stepper("Automatic retries: \(retryCount)", value: $retryCount, in: 0...10)
Stepper("Initial retry delay: \(retryDelaySeconds) seconds", value: $retryDelaySeconds, in: 1...300)
Text("Retries apply to scheduled and file-watch runs and use an increasing delay between attempts.")
.font(.caption)
.foregroundStyle(.secondary)
Toggle("Notify when a run fails", isOn: $notifyOnFailure)
Toggle("Notify when a run succeeds", isOn: $notifyOnSuccess)
}
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)
if deleteExtraneous && (scheduleEnabled || watchEnabled) {
Toggle("Allow unattended delete", isOn: $allowUnattendedDelete)
Text("Required before scheduled or file-watch runs may use --delete. Enable only after validating the job with Dry Run.")
.font(.caption)
.foregroundStyle(.orange)
}
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))
Text("Transport, password, remote-command and destructive options are blocked here. Use the dedicated controls above for SSH and --delete.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.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) }
}
}
}
.alert("Invalid Sync Job", isPresented: Binding(
get: { validationMessage != nil },
set: { if !$0 { validationMessage = nil } }
)) {
Button("OK", role: .cancel) { validationMessage = nil }
} message: {
Text(validationMessage ?? "The job configuration is invalid.")
}
.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.allowUnattendedDelete = deleteExtraneous ? allowUnattendedDelete : false
job.retryCount = retryCount
job.retryDelaySeconds = retryDelaySeconds
job.notifyOnFailure = notifyOnFailure
job.notifyOnSuccess = notifyOnSuccess
job.requireExpectedVolume = !isRemote && requireExpectedVolume
job.expectedVolumeIdentifier = !isRemote && requireExpectedVolume ? expectedVolumeIdentifier : ""
job.minimumFreeSpaceMB = minimumFreeSpaceMB
job.updatedAt = Date()
let validationTrigger: SyncRunTrigger = (job.scheduleEnabled || job.watchEnabled) ? .scheduled : .manual
if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: validationTrigger) {
validationMessage = message
return
}
do {
if existingJob == nil { modelContext.insert(job) }
try modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
dismiss()
} catch {
validationMessage = "Could not save the job: \(error.localizedDescription)"
}
}
}
+523
View File
@@ -0,0 +1,523 @@
import SwiftUI
import SwiftData
import AppKit
import UniformTypeIdentifiers
struct JobListView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject private var automation: BackgroundSyncCoordinator
@Query(sort: \SyncJob.name) private var jobs: [SyncJob]
@Binding var selection: SyncJob?
@Binding var showingNewJob: Bool
@State private var searchText = ""
@State private var filter: JobFilter = .all
@State private var persistenceError: String?
@State private var transferMessage: String?
var body: some View {
VStack(spacing: 0) {
dashboardHeader
Picker("Filter", selection: $filter) {
ForEach(JobFilter.allCases) { item in
Text(item.title).tag(item)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 14)
.padding(.bottom, 10)
Divider()
if filteredJobs.isEmpty {
emptyState
} else {
List(selection: $selection) {
ForEach(filteredJobs) { job in
JobRow(job: job, isRunning: automationActivityMatches(job))
.tag(job)
.listRowInsets(EdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10))
.contextMenu {
Button("Open Job") { selection = job }
Button("Duplicate") { duplicate(job) }
Divider()
Button(job.isEnabled ? "Disable" : "Enable") {
job.isEnabled.toggle()
job.updatedAt = Date()
saveAndRefresh()
}
Button("Delete", role: .destructive) { delete(job) }
}
}
}
.listStyle(.sidebar)
}
}
.alert("Database Error", isPresented: Binding(
get: { persistenceError != nil },
set: { if !$0 { persistenceError = nil } }
)) {
Button("OK", role: .cancel) { persistenceError = nil }
} message: {
Text(persistenceError ?? "Unable to save job changes.")
}
.alert("Job Configuration", isPresented: Binding(
get: { transferMessage != nil },
set: { if !$0 { transferMessage = nil } }
)) {
Button("OK", role: .cancel) { transferMessage = nil }
} message: {
Text(transferMessage ?? "")
}
}
private var dashboardHeader: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Jobs")
.font(.headline)
Text("\(jobs.count) configured job\(jobs.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
statusPill
Menu {
Button("Import Jobs…", systemImage: "square.and.arrow.down") { importJobs() }
Button("Export Jobs…", systemImage: "square.and.arrow.up") { exportJobs() }
} label: {
Image(systemName: "ellipsis.circle")
}
.menuStyle(.borderlessButton)
.help("Import or export job configurations")
Button {
showingNewJob = true
} label: {
Image(systemName: "plus")
}
.buttonStyle(.borderedProminent)
.help("New Sync Job")
}
HStack(spacing: 8) {
TextField("Search jobs", text: $searchText)
.textFieldStyle(.roundedBorder)
if automation.isSyncAllRunning {
Button(role: .destructive) {
automation.cancelSyncAll()
} label: {
Label("Cancel All", systemImage: "stop.fill")
}
.buttonStyle(.bordered)
.help("Cancel the current Sync All batch")
} else {
Button {
automation.syncAllEnabledJobs()
} label: {
Label("Sync All", systemImage: "arrow.triangle.2.circlepath")
}
.buttonStyle(.bordered)
.disabled(jobs.allSatisfy { !$0.isEnabled })
.help("Run every enabled sync job in sequence")
}
}
HStack(spacing: 9) {
metricCard(value: automation.scheduledJobCount, title: "Scheduled", icon: "calendar.badge.clock", colour: .blue)
metricCard(value: automation.watchedJobCount, title: "Watching", icon: "eye.fill", colour: .purple)
}
if automation.isSyncAllRunning {
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: Double(automation.syncAllCompleted + automation.syncAllFailed + automation.syncAllSkipped), total: Double(max(1, automation.syncAllTotal)))
HStack {
Text("Sync All · \(automation.syncAllCompleted + automation.syncAllFailed + automation.syncAllSkipped) of \(automation.syncAllTotal)")
Spacer()
Text("✓ \(automation.syncAllCompleted) ! \(automation.syncAllFailed) – \(automation.syncAllSkipped)")
}
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.padding(14)
}
@ViewBuilder
private var statusPill: some View {
if !automation.isNetworkAvailable {
Label("Offline", systemImage: "wifi.slash")
.font(.caption.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(Color.orange.opacity(0.10), in: Capsule())
} else if automation.activity == .idle {
Label("Ready", systemImage: "checkmark.circle.fill")
.font(.caption.weight(.semibold))
.foregroundStyle(.green)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(Color.green.opacity(0.10), in: Capsule())
} else {
Label(automation.activityDisplayTitle, systemImage: "arrow.triangle.2.circlepath")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.accentColor)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(Color.accentColor.opacity(0.10), in: Capsule())
}
}
private func metricCard(value: Int, title: String, icon: String, colour: Color) -> some View {
HStack(spacing: 9) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(colour.opacity(0.11))
Image(systemName: icon)
.foregroundStyle(colour)
}
.frame(width: 30, height: 30)
VStack(alignment: .leading, spacing: 0) {
Text("\(value)")
.font(.headline)
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(9)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.10))
}
}
private var emptyState: some View {
ContentUnavailableView(
searchText.isEmpty ? "No Jobs" : "No Matching Jobs",
systemImage: searchText.isEmpty ? "arrow.left.arrow.right.square" : "magnifyingglass",
description: Text(searchText.isEmpty ? "Create a sync job to get started." : "Try a different search or filter.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var filteredJobs: [SyncJob] {
jobs.filter { job in
let matchesSearch = searchText.isEmpty ||
job.name.localizedCaseInsensitiveContains(searchText) ||
job.sourcePath.localizedCaseInsensitiveContains(searchText) ||
job.destinationDisplay.localizedCaseInsensitiveContains(searchText)
let matchesFilter: Bool
switch filter {
case .all: matchesFilter = true
case .scheduled: matchesFilter = job.scheduleEnabled && job.scheduleKind != .manual
case .watching: matchesFilter = job.watchEnabled
case .attention: matchesFilter = job.lastExitCode != nil && job.lastExitCode != 0
}
return matchesSearch && matchesFilter
}
}
private func automationActivityMatches(_ job: SyncJob) -> Bool {
if case .running(let name) = automation.activity { return name == job.name }
return false
}
private func duplicate(_ job: SyncJob) {
let copy = SyncJob(name: job.name + " Copy", sourcePath: job.sourcePath, destinationPath: job.destinationPath)
copy.isRemote = job.isRemote
copy.remoteHost = job.remoteHost
copy.remoteUser = job.remoteUser
copy.remotePort = job.remotePort
copy.sshKeyPath = job.sshKeyPath
copy.archive = job.archive
copy.compress = job.compress
copy.deleteExtraneous = job.deleteExtraneous
copy.preservePermissions = job.preservePermissions
copy.preserveExtendedAttributes = job.preserveExtendedAttributes
copy.showProgress = job.showProgress
copy.exclusionsText = job.exclusionsText
copy.additionalArguments = job.additionalArguments
copy.isEnabled = job.isEnabled
copy.scheduleEnabled = job.scheduleEnabled
copy.scheduleKind = job.scheduleKind
copy.scheduleStartAt = job.scheduleStartAt
copy.scheduleIntervalMinutes = job.scheduleIntervalMinutes
copy.nextRunAt = copy.calculateNextRun(after: Date().addingTimeInterval(-1))
copy.watchEnabled = job.watchEnabled
copy.watchPath = job.watchPath
copy.watchDebounceSeconds = job.watchDebounceSeconds
copy.allowUnattendedDelete = job.allowUnattendedDelete
copy.retryCount = job.retryCount
copy.retryDelaySeconds = job.retryDelaySeconds
copy.notifyOnFailure = job.notifyOnFailure
copy.notifyOnSuccess = job.notifyOnSuccess
copy.requireExpectedVolume = job.requireExpectedVolume
copy.expectedVolumeIdentifier = job.expectedVolumeIdentifier
copy.minimumFreeSpaceMB = job.minimumFreeSpaceMB
modelContext.insert(copy)
saveAndRefresh()
selection = copy
}
private func exportJobs() {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "Rsync Studio-Jobs.json"
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let document = JobTransferDocument(version: 1, exportedAt: Date(), jobs: jobs.map(JobTransferJob.init))
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
try encoder.encode(document).write(to: url, options: .atomic)
transferMessage = "Exported \(jobs.count) job configuration(s). SSH private-key paths are intentionally excluded."
} catch { persistenceError = "Could not export jobs: \(error.localizedDescription)" }
}
private func importJobs() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let document = try decoder.decode(JobTransferDocument.self, from: Data(contentsOf: url))
guard document.version == 1 else { throw JobTransferError.unsupportedVersion }
var count = 0
for item in document.jobs {
let job = item.makeJob()
modelContext.insert(job)
count += 1
}
try modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
transferMessage = "Imported \(count) job configuration(s). Remote jobs are disabled until you select a private key or confirm SSH-agent access."
} catch { persistenceError = "Could not import jobs: \(error.localizedDescription)" }
}
private func delete(_ job: SyncJob) {
if selection?.id == job.id { selection = nil }
modelContext.delete(job)
saveAndRefresh()
}
private func saveAndRefresh() {
do {
try modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
} catch {
persistenceError = error.localizedDescription
}
}
}
private struct JobRow: View {
let job: SyncJob
let isRunning: Bool
var body: some View {
HStack(alignment: .top, spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(iconColour.opacity(0.12))
Image(systemName: job.isRemote ? "server.rack" : "folder.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(iconColour)
}
.frame(width: 38, height: 38)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 7) {
Text(job.name)
.font(.body.weight(.semibold))
.lineLimit(1)
if isRunning {
AnimatedSyncIndicator(size: 15, lineWidth: 2.2)
}
Spacer()
statusIcon
}
pathSummary
HStack(spacing: 6) {
if job.scheduleEnabled && job.scheduleKind != .manual {
miniBadge(job.scheduleKind.title, icon: "calendar.badge.clock", colour: .blue)
}
if job.watchEnabled {
miniBadge("Watching", icon: "eye.fill", colour: .purple)
}
if !job.isEnabled {
miniBadge("Disabled", icon: "pause.fill", colour: .secondary)
}
}
Text(timingText)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
.padding(10)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.secondary.opacity(0.09))
}
.opacity(job.isEnabled ? 1 : 0.62)
}
private var pathSummary: some View {
HStack(spacing: 5) {
Text(shortPath(job.sourcePath))
.lineLimit(1)
.truncationMode(.middle)
Image(systemName: "arrow.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
Text(shortPath(job.destinationDisplay))
.lineLimit(1)
.truncationMode(.middle)
}
.font(.caption)
.foregroundStyle(.secondary)
}
@ViewBuilder
private var statusIcon: some View {
if isRunning {
Image(systemName: "arrow.triangle.2.circlepath.circle.fill")
.foregroundStyle(Color.accentColor)
} else if job.lastExitCode == 0 {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
} else if job.lastExitCode != nil {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
} else {
Image(systemName: "circle.dashed")
.foregroundStyle(.tertiary)
}
}
private var timingText: String {
if let next = job.nextRunAt, job.scheduleEnabled {
return "Next run \(next.formatted(.relative(presentation: .named)))"
}
if let last = job.lastRunAt {
return "Last run \(last.formatted(.relative(presentation: .named)))"
}
return "Not run yet"
}
private var iconColour: Color {
if !job.isEnabled { return .secondary }
return job.isRemote ? .indigo : .blue
}
private func shortPath(_ path: String) -> String {
guard path.count > 42 else { return path }
return "…" + path.suffix(41)
}
private func miniBadge(_ title: String, icon: String, colour: Color) -> some View {
Label(title, systemImage: icon)
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(colour)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(colour.opacity(0.10), in: Capsule())
}
}
private enum JobFilter: String, CaseIterable, Identifiable {
case all, scheduled, watching, attention
var id: String { rawValue }
var title: String {
switch self {
case .all: return "All"
case .scheduled: return "Scheduled"
case .watching: return "Watching"
case .attention: return "Issues"
}
}
}
private struct JobTransferDocument: Codable {
let version: Int
let exportedAt: Date
let jobs: [JobTransferJob]
}
private struct JobTransferJob: Codable {
let name, sourcePath, destinationPath: String
let isRemote: Bool
let remoteHost, remoteUser: String
let remotePort: Int
let archive, compress, deleteExtraneous, preservePermissions, preserveExtendedAttributes, showProgress: Bool
let exclusionsText, additionalArguments: String
let isEnabled: Bool
let scheduleEnabled: Bool
let scheduleKindRaw: String
let scheduleStartAt: Date
let scheduleIntervalMinutes: Int
let watchEnabled: Bool
let watchPath: String
let watchDebounceSeconds: Int
let allowUnattendedDelete: Bool
let retryCount, retryDelaySeconds: Int
let notifyOnFailure, notifyOnSuccess: Bool
let requireExpectedVolume: Bool
let expectedVolumeIdentifier: String
let minimumFreeSpaceMB: Int
init(_ job: SyncJob) {
name = job.name; sourcePath = job.sourcePath; destinationPath = job.destinationPath
isRemote = job.isRemote; remoteHost = job.remoteHost; remoteUser = job.remoteUser; remotePort = job.remotePort
archive = job.archive; compress = job.compress; deleteExtraneous = job.deleteExtraneous
preservePermissions = job.preservePermissions; preserveExtendedAttributes = job.preserveExtendedAttributes; showProgress = job.showProgress
exclusionsText = job.exclusionsText; additionalArguments = job.additionalArguments; isEnabled = job.isEnabled
scheduleEnabled = job.scheduleEnabled; scheduleKindRaw = job.scheduleKindRaw; scheduleStartAt = job.scheduleStartAt; scheduleIntervalMinutes = job.scheduleIntervalMinutes
watchEnabled = job.watchEnabled; watchPath = job.watchPath; watchDebounceSeconds = job.watchDebounceSeconds
allowUnattendedDelete = job.allowUnattendedDelete; retryCount = job.retryCount; retryDelaySeconds = job.retryDelaySeconds
notifyOnFailure = job.notifyOnFailure; notifyOnSuccess = job.notifyOnSuccess
requireExpectedVolume = job.requireExpectedVolume; expectedVolumeIdentifier = job.expectedVolumeIdentifier; minimumFreeSpaceMB = job.minimumFreeSpaceMB
}
func makeJob() -> SyncJob {
let job = SyncJob(name: name, sourcePath: sourcePath, destinationPath: destinationPath)
job.isRemote = isRemote; job.remoteHost = remoteHost; job.remoteUser = remoteUser; job.remotePort = remotePort; job.sshKeyPath = ""
job.archive = archive; job.compress = compress; job.deleteExtraneous = deleteExtraneous; job.preservePermissions = preservePermissions
job.preserveExtendedAttributes = preserveExtendedAttributes; job.showProgress = showProgress; job.exclusionsText = exclusionsText; job.additionalArguments = additionalArguments
job.isEnabled = isRemote ? false : isEnabled
job.scheduleEnabled = isRemote ? false : scheduleEnabled; job.scheduleKindRaw = scheduleKindRaw; job.scheduleStartAt = scheduleStartAt; job.scheduleIntervalMinutes = scheduleIntervalMinutes
job.nextRunAt = job.scheduleEnabled ? job.calculateNextRun(after: Date().addingTimeInterval(-1)) : nil
job.watchEnabled = isRemote ? false : watchEnabled; job.watchPath = watchPath; job.watchDebounceSeconds = watchDebounceSeconds
job.allowUnattendedDelete = allowUnattendedDelete; job.retryCount = retryCount; job.retryDelaySeconds = retryDelaySeconds
job.notifyOnFailure = notifyOnFailure; job.notifyOnSuccess = notifyOnSuccess; job.requireExpectedVolume = requireExpectedVolume
job.expectedVolumeIdentifier = expectedVolumeIdentifier; job.minimumFreeSpaceMB = minimumFreeSpaceMB
return job
}
}
private enum JobTransferError: LocalizedError {
case unsupportedVersion
var errorDescription: String? { "This job configuration file uses an unsupported schema version." }
}
@@ -0,0 +1,199 @@
import SwiftUI
import SwiftData
import AppKit
import UniformTypeIdentifiers
struct RunHistoryView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun]
@State private var selection: SyncRun?
@State private var persistenceError: String?
@State private var searchText = ""
@State private var filter: HistoryFilter = .all
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()
Menu {
Button("Export Selected…") { exportRuns(selection.map { [$0] } ?? []) }
.disabled(selection == nil)
Button("Export All…") { exportRuns(runs) }
.disabled(runs.isEmpty)
} label: {
Image(systemName: "square.and.arrow.up")
}
.menuStyle(.borderlessButton)
.help("Export run logs")
Button("Clear", role: .destructive) {
runs.forEach(modelContext.delete)
selection = nil
saveHistoryChanges()
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(runs.isEmpty)
}
.padding(14)
VStack(spacing: 8) {
TextField("Search history", text: $searchText)
.textFieldStyle(.roundedBorder)
Picker("History filter", selection: $filter) {
ForEach(HistoryFilter.allCases) { item in Text(item.title).tag(item) }
}
.pickerStyle(.segmented)
.labelsHidden()
}
.padding(.horizontal, 14)
.padding(.bottom, 10)
Divider()
if filteredRuns.isEmpty {
ContentUnavailableView(
"No Run History",
systemImage: "clock.arrow.circlepath",
description: Text(runs.isEmpty ? "Completed, failed and background synchronisations will appear here." : "No runs match the current search or filter.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(selection: $selection) {
ForEach(filteredRuns) { 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)
saveHistoryChanges()
}
}
}
}
.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)
.alert("Database Error", isPresented: Binding(
get: { persistenceError != nil },
set: { if !$0 { persistenceError = nil } }
)) {
Button("OK", role: .cancel) { persistenceError = nil }
} message: {
Text(persistenceError ?? "Unable to update run history.")
}
}
private var filteredRuns: [SyncRun] {
runs.filter { run in
let matchesSearch = searchText.isEmpty || run.jobName.localizedCaseInsensitiveContains(searchText) || run.output.localizedCaseInsensitiveContains(searchText) || run.errorOutput.localizedCaseInsensitiveContains(searchText)
let matchesFilter: Bool
switch filter {
case .all: matchesFilter = true
case .failed: matchesFilter = !run.isRunning && !run.succeeded && !run.wasCancelled
case .successful: matchesFilter = run.succeeded
case .automatic: matchesFilter = run.trigger == .scheduled || run.trigger == .watched
}
return matchesSearch && matchesFilter
}
}
private func exportRuns(_ items: [SyncRun]) {
guard !items.isEmpty else { return }
let panel = NSSavePanel()
panel.allowedContentTypes = [.plainText]
panel.nameFieldStringValue = items.count == 1 ? "Rsync Studio-\(items[0].jobName)-Run.txt" : "Rsync Studio-History.txt"
guard panel.runModal() == .OK, let url = panel.url else { return }
let text = items.map(\.exportText).joined(separator: "\n\n" + String(repeating: "=", count: 72) + "\n\n")
do { try text.write(to: url, atomically: true, encoding: .utf8) }
catch { persistenceError = "Could not export history: \(error.localizedDescription)" }
}
private func saveHistoryChanges() {
do { try modelContext.save() }
catch { persistenceError = error.localizedDescription }
}
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
}
}
private enum HistoryFilter: String, CaseIterable, Identifiable {
case all, failed, successful, automatic
var id: String { rawValue }
var title: String {
switch self {
case .all: return "All"
case .failed: return "Failed"
case .successful: return "Success"
case .automatic: return "Automatic"
}
}
}
+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)" }
}