import SwiftUI struct SettingsView: View { @AppStorage(SyncSettings.enabledKey) private var enabled = false @AppStorage(SyncSettings.remoteKey) private var remote = "" @AppStorage(SyncSettings.dirKey) private var dir = "" // Stored as text so edits commit per keystroke (a format-based numeric // field only commits on Return/focus loss, which a button click isn't). @AppStorage(SyncSettings.portKey) private var portText = "22" @AppStorage(PGSettings.autoIngestKey) private var pgAutoIngest = false @AppStorage(PGSettings.uriKey) private var pgURI = PGSettings.defaultURI @AppStorage(AppSettings.boardPageSizeKey) private var boardPageSize = "letter" @AppStorage(AppSettings.boardOrientationKey) private var boardOrientation = "portrait" @AppStorage(AppSettings.boardSnapKey) private var boardSnap = true @State private var testing = false @State private var testMessage: String? @State private var testSucceeded = false // Bumped on every config edit so an in-flight test of the old config // can't land its result on the new one. @State private var testGeneration = 0 @State private var pgTesting = false @State private var pgMessage: String? @State private var pgSucceeded = false private var trimmedRemote: String { remote.trimmingCharacters(in: .whitespaces) } private var portValue: Int? { let t = portText.trimmingCharacters(in: .whitespaces) if t.isEmpty { return 22 } guard let p = Int(t), (1...65535).contains(p) else { return nil } return p } var body: some View { Form { Section("Board") { Picker("New page size:", selection: $boardPageSize) { ForEach(PageSize.allCases, id: \.rawValue) { ps in Text(ps.label).tag(ps.rawValue) } } Picker("New page orientation:", selection: $boardOrientation) { Text("Portrait").tag("portrait") Text("Landscape").tag("landscape") } Toggle("Snap to grid while dragging", isOn: $boardSnap) Text("Defaults for new documents and new text pages. Change the current page's size anytime with the size popup and P/L control in Board mode.") .font(.caption) .foregroundStyle(.secondary) } Section("Tailnet sync") { Toggle("Copy file to remote Mac on every save", isOn: $enabled) if enabled && trimmedRemote.isEmpty { Label("Sync is on, but no remote is set below — saves won't be copied", systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } } Section("Remote (over Tailscale)") { TextField("Remote:", text: $remote, prompt: Text("user@tailscale-hostname")) .autocorrectionDisabled() TextField("Folder:", text: $dir, prompt: Text("/Users/you/Documents/inbox or ~/inbox")) .autocorrectionDisabled() TextField("SSH port:", text: $portText, prompt: Text("22")) if portValue == nil { Label("Port must be a number from 1–65535", systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.red) } } Section { HStack(spacing: 10) { Button(testing ? "Testing…" : "Test Connection") { runTest() } .disabled(testing || trimmedRemote.isEmpty || portValue == nil) if testing { ProgressView().controlSize(.small) } else if let message = testMessage { Label(message, systemImage: testSucceeded ? "checkmark.circle.fill" : "xmark.circle.fill") .foregroundStyle(testSucceeded ? Color.green : Color.red) .lineLimit(3) .textSelection(.enabled) } } Text("Uses your SSH keys (no password prompts). `ssh \(trimmedRemote.isEmpty ? "user@host" : trimmedRemote)` must work without typing a password — see the README for one-time key setup. The target Mac needs Remote Login enabled in System Settings → Sharing.") .font(.caption) .foregroundStyle(.secondary) } Section("Postgres (scraper ingest)") { Toggle("Auto-ingest HTML pages on save", isOn: $pgAutoIngest) TextField("Connection URI:", text: $pgURI, prompt: Text(PGSettings.defaultURI)) .autocorrectionDisabled() HStack(spacing: 10) { Button(pgTesting ? "Testing…" : "Test & Create Tables") { runPGTest() } .disabled(pgTesting) if pgTesting { ProgressView().controlSize(.small) } else if let message = pgMessage { Label(message, systemImage: pgSucceeded ? "checkmark.circle.fill" : "xmark.circle.fill") .foregroundStyle(pgSucceeded ? Color.green : Color.red) .lineLimit(3) .textSelection(.enabled) } } Text(pgFooter) .font(.caption) .foregroundStyle(.secondary) } } .formStyle(.grouped) .frame(width: 480) .fixedSize(horizontal: false, vertical: true) .onChange(of: remote) { invalidateTest() } .onChange(of: dir) { invalidateTest() } .onChange(of: portText) { invalidateTest() } } private var pgFooter: String { let psql = PostgresStore.psqlPath.map { "psql: \($0)" } ?? "psql not found — brew install postgresql@17" return "On save, documents that look like HTML (URL on the first line, then pasted ) are parsed and stored: page + selector summary, and Discogs marketplace pages also upsert discogs_sellers / marketplace_snapshots. \(psql)" } private func runPGTest() { pgTesting = true pgMessage = nil let uri = pgURI.trimmingCharacters(in: .whitespaces) PostgresStore.test(uri: uri.isEmpty ? PGSettings.defaultURI : uri) { result in pgTesting = false switch result { case .success(let message): pgSucceeded = true pgMessage = message case .failure(let error): pgSucceeded = false pgMessage = error.message } } } private func invalidateTest() { testGeneration += 1 testMessage = nil testing = false } private func runTest() { testGeneration += 1 let generation = testGeneration testing = true testMessage = nil SyncEngine.testConnection(remote: trimmedRemote, port: portValue ?? 22, dir: dir) { result in guard generation == testGeneration else { return } testing = false switch result { case .success(let message): testSucceeded = true testMessage = message case .failure(let error): testSucceeded = false testMessage = error.message } } } }