Initial Commit
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
enum JobEditorSection: Hashable {
|
||||
case schedule
|
||||
case fileWatch
|
||||
}
|
||||
|
||||
struct JobEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@AppStorage("defaultSSHUsername") private var defaultSSHUsername = NSUserName()
|
||||
|
||||
let existingJob: SyncJob?
|
||||
let initialSection: JobEditorSection?
|
||||
|
||||
@State private var name: String
|
||||
@State private var sourcePath: String
|
||||
@State private var destinationPath: String
|
||||
@State private var isRemote: Bool
|
||||
@State private var remoteHost: String
|
||||
@State private var remoteUser: String
|
||||
@State private var remotePort: Int
|
||||
@State private var sshKeyPath: String
|
||||
@State private var archive: Bool
|
||||
@State private var compress: Bool
|
||||
@State private var deleteExtraneous: Bool
|
||||
@State private var preservePermissions: Bool
|
||||
@State private var preserveExtendedAttributes: Bool
|
||||
@State private var showProgress: Bool
|
||||
@State private var exclusionsText: String
|
||||
@State private var additionalArguments: String
|
||||
@State private var isEnabled: Bool
|
||||
@State private var scheduleEnabled: Bool
|
||||
@State private var scheduleKind: SyncScheduleKind
|
||||
@State private var scheduleStartAt: Date
|
||||
@State private var scheduleIntervalMinutes: Int
|
||||
@State private var watchEnabled: Bool
|
||||
@State private var watchPath: String
|
||||
@State private var watchDebounceSeconds: Int
|
||||
|
||||
init(job: SyncJob? = nil, initialSection: JobEditorSection? = nil) {
|
||||
self.existingJob = job
|
||||
self.initialSection = initialSection
|
||||
_name = State(initialValue: job?.name ?? "New Sync Job")
|
||||
_sourcePath = State(initialValue: job?.sourcePath ?? "")
|
||||
_destinationPath = State(initialValue: job?.destinationPath ?? "")
|
||||
_isRemote = State(initialValue: job?.isRemote ?? false)
|
||||
_remoteHost = State(initialValue: job?.remoteHost ?? UserDefaults.standard.string(forKey: "defaultRemoteHost") ?? "")
|
||||
_remoteUser = State(initialValue: job?.remoteUser ?? UserDefaults.standard.string(forKey: "defaultSSHUsername") ?? NSUserName())
|
||||
let savedPort = UserDefaults.standard.integer(forKey: "defaultSSHPort")
|
||||
_remotePort = State(initialValue: job?.remotePort ?? (savedPort == 0 ? 22 : savedPort))
|
||||
_sshKeyPath = State(initialValue: job?.sshKeyPath ?? UserDefaults.standard.string(forKey: "defaultSSHKeyPath") ?? "")
|
||||
_archive = State(initialValue: job?.archive ?? true)
|
||||
_compress = State(initialValue: job?.compress ?? false)
|
||||
_deleteExtraneous = State(initialValue: job?.deleteExtraneous ?? false)
|
||||
_preservePermissions = State(initialValue: job?.preservePermissions ?? true)
|
||||
_preserveExtendedAttributes = State(initialValue: job?.preserveExtendedAttributes ?? true)
|
||||
_showProgress = State(initialValue: job?.showProgress ?? true)
|
||||
_exclusionsText = State(initialValue: job?.exclusionsText ?? "")
|
||||
_additionalArguments = State(initialValue: job?.additionalArguments ?? "")
|
||||
_isEnabled = State(initialValue: job?.isEnabled ?? true)
|
||||
_scheduleEnabled = State(initialValue: job?.scheduleEnabled ?? false)
|
||||
_scheduleKind = State(initialValue: job?.scheduleKind ?? .daily)
|
||||
_scheduleStartAt = State(initialValue: job?.scheduleStartAt ?? Date().addingTimeInterval(3600))
|
||||
_scheduleIntervalMinutes = State(initialValue: job?.scheduleIntervalMinutes ?? 30)
|
||||
_watchEnabled = State(initialValue: job?.watchEnabled ?? false)
|
||||
_watchPath = State(initialValue: job?.watchPath ?? "")
|
||||
let defaultDebounce = UserDefaults.standard.integer(forKey: "defaultWatchDebounceSeconds")
|
||||
_watchDebounceSeconds = State(initialValue: job?.watchDebounceSeconds ?? (defaultDebounce == 0 ? 3 : defaultDebounce))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollViewReader { proxy in
|
||||
Form {
|
||||
Section("Job") {
|
||||
TextField("Name", text: $name)
|
||||
Toggle("Enabled", isOn: $isEnabled)
|
||||
}
|
||||
|
||||
Section("Locations") {
|
||||
PathField(title: "Source", path: $sourcePath)
|
||||
Picker("Destination type", selection: $isRemote) {
|
||||
Text("Local / Mounted Volume").tag(false)
|
||||
Text("SSH / Remote Mac or Server").tag(true)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
if isRemote {
|
||||
TextField("Remote path", text: $destinationPath, prompt: Text("/home/mag/backup or ~/backup"))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
} else {
|
||||
PathField(title: "Destination", path: $destinationPath)
|
||||
}
|
||||
}
|
||||
|
||||
if isRemote {
|
||||
Section("SSH") {
|
||||
TextField("Host", text: $remoteHost, prompt: Text("pegasus.local"))
|
||||
TextField("User", text: $remoteUser, prompt: Text(defaultSSHUsername.isEmpty ? NSUserName() : defaultSSHUsername))
|
||||
Stepper("Port: \(remotePort)", value: $remotePort, in: 1...65535)
|
||||
HStack {
|
||||
TextField("Private SSH key", text: $sshKeyPath, prompt: Text("~/.ssh/id_ed25519 or ~/.ssh/id_rsa"))
|
||||
Button("Choose…") {
|
||||
if let selected = FilePicker.chooseFile() { sshKeyPath = selected }
|
||||
}
|
||||
}
|
||||
if sshKeyPath.hasSuffix(".pub") {
|
||||
Label("Select the private key, not the .pub public key.", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Run this job on a schedule", isOn: $scheduleEnabled)
|
||||
if scheduleEnabled {
|
||||
Picker("Frequency", selection: $scheduleKind) {
|
||||
ForEach(SyncScheduleKind.allCases.filter { $0 != .manual }) { kind in
|
||||
Label(kind.title, systemImage: kind.systemImage).tag(kind)
|
||||
}
|
||||
}
|
||||
|
||||
DatePicker(
|
||||
scheduleKind == .once ? "Run date and time" : "Starting",
|
||||
selection: $scheduleStartAt,
|
||||
displayedComponents: scheduleKind == .once ? [.date, .hourAndMinute] : [.date, .hourAndMinute]
|
||||
)
|
||||
|
||||
if scheduleKind == .custom {
|
||||
Stepper("Every \(scheduleIntervalMinutes) minutes", value: $scheduleIntervalMinutes, in: 1...525600)
|
||||
}
|
||||
|
||||
Label(schedulePreview, systemImage: "calendar.badge.clock")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("Schedule")
|
||||
} footer: {
|
||||
Text("Scheduled jobs run silently and are recorded in History. They continue while Rsync Manager is running, even when its window is in the background.")
|
||||
}
|
||||
.id(JobEditorSection.schedule)
|
||||
|
||||
Section {
|
||||
Toggle("Watch for file changes", isOn: $watchEnabled)
|
||||
if watchEnabled {
|
||||
HStack {
|
||||
TextField("Watch path", text: $watchPath, prompt: Text(sourcePath.isEmpty ? "Uses the job source" : sourcePath))
|
||||
Menu("Choose…") {
|
||||
Button("Choose Folder…") {
|
||||
if let selected = FilePicker.chooseDirectory() { watchPath = selected }
|
||||
}
|
||||
Button("Choose File…") {
|
||||
if let selected = FilePicker.chooseFile() { watchPath = selected }
|
||||
}
|
||||
}
|
||||
}
|
||||
Stepper("Wait \(watchDebounceSeconds) seconds after changes", value: $watchDebounceSeconds, in: 1...60)
|
||||
Label("Changes are monitored with macOS FSEvents. Bursts of edits are combined into one sync.", systemImage: "waveform.path.ecg")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("File Watching")
|
||||
} footer: {
|
||||
Text("Leave the watch path empty to watch the source. If the source is a single file, only that file is synced; if it is a folder, the configured folder job runs when anything inside changes.")
|
||||
}
|
||||
.id(JobEditorSection.fileWatch)
|
||||
|
||||
Section("Rsync Options") {
|
||||
Toggle("Archive mode (-a)", isOn: $archive)
|
||||
Toggle("Compress during transfer (-z)", isOn: $compress)
|
||||
Toggle("Delete files missing from source (--delete)", isOn: $deleteExtraneous)
|
||||
Toggle("Preserve permissions", isOn: $preservePermissions)
|
||||
Toggle("Preserve extended attributes", isOn: $preserveExtendedAttributes)
|
||||
Toggle("Show transfer progress", isOn: $showProgress)
|
||||
}
|
||||
|
||||
Section("Exclusions") {
|
||||
TextEditor(text: $exclusionsText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 90)
|
||||
Text("One exclusion pattern per line.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Advanced") {
|
||||
TextField("Additional rsync arguments", text: $additionalArguments)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(existingJob == nil ? "New Sync Job" : "Edit Sync Job")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { save() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!canSave)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
guard let initialSection else { return }
|
||||
try? await Task.sleep(for: .milliseconds(120))
|
||||
withAnimation { proxy.scrollTo(initialSection, anchor: .top) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 690, height: 820)
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
!sourcePath.isEmpty &&
|
||||
!destinationPath.isEmpty &&
|
||||
(!isRemote || !remoteHost.isEmpty)
|
||||
}
|
||||
|
||||
private var schedulePreview: String {
|
||||
let previewJob = SyncJob()
|
||||
previewJob.scheduleEnabled = scheduleEnabled
|
||||
previewJob.scheduleKind = scheduleKind
|
||||
previewJob.scheduleStartAt = scheduleStartAt
|
||||
previewJob.scheduleIntervalMinutes = scheduleIntervalMinutes
|
||||
if let next = previewJob.calculateNextRun() {
|
||||
return "Next run: \(next.formatted(date: .abbreviated, time: .shortened))"
|
||||
}
|
||||
return "No future run is currently scheduled."
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let job = existingJob ?? SyncJob()
|
||||
job.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
job.sourcePath = sourcePath
|
||||
job.destinationPath = destinationPath
|
||||
job.isRemote = isRemote
|
||||
job.remoteHost = remoteHost
|
||||
job.remoteUser = remoteUser
|
||||
job.remotePort = remotePort
|
||||
job.sshKeyPath = sshKeyPath
|
||||
job.archive = archive
|
||||
job.compress = compress
|
||||
job.deleteExtraneous = deleteExtraneous
|
||||
job.preservePermissions = preservePermissions
|
||||
job.preserveExtendedAttributes = preserveExtendedAttributes
|
||||
job.showProgress = showProgress
|
||||
job.exclusionsText = exclusionsText
|
||||
job.additionalArguments = additionalArguments
|
||||
job.isEnabled = isEnabled
|
||||
job.scheduleEnabled = scheduleEnabled
|
||||
job.scheduleKind = scheduleEnabled ? scheduleKind : .manual
|
||||
job.scheduleStartAt = scheduleStartAt
|
||||
job.scheduleIntervalMinutes = scheduleIntervalMinutes
|
||||
job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1))
|
||||
job.watchEnabled = watchEnabled
|
||||
job.watchPath = watchPath
|
||||
job.watchDebounceSeconds = watchDebounceSeconds
|
||||
job.updatedAt = Date()
|
||||
if existingJob == nil { modelContext.insert(job) }
|
||||
try? modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user