latest updates
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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...]))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)" }
|
||||
}
|
||||
Reference in New Issue
Block a user