textplus/Sources/SettingsView.swift
monster 09972ccde1 TextPlus: native macOS plaintext editor with sync, scraping, and a freeform board
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>
2026-06-14 16:08:59 +10:00

171 lines
7.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 165535",
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
}
}
}
}