A no-Xcode AppKit app (swiftc + build.sh): - Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find, live Markdown preview (split mode), selection-aware Markdown toolbar. - Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape, US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel), freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich status bar. The Editor follows the selected page; layout persists to a foo.txt.board sidecar; the .txt stays pure plaintext. - scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain). - HTML scraping pipeline: custom selector engine + Discogs profile, ingests to Postgres (psql) into a marketplace_snapshots schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
171 lines
7.6 KiB
Swift
171 lines
7.6 KiB
Swift
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 <body>) 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
|
||
}
|
||
}
|
||
}
|
||
}
|