PadXcode-iPad/Settings/SettingsView.swift
2026-04-12 00:46:30 -07:00

176 lines
11 KiB
Swift

import SwiftUI
import UIKit
struct SettingsView: View {
@EnvironmentObject var daemonConfig: DaemonConfiguration
@ObservedObject var editorState: EditorState
@ObservedObject var projectStore: ProjectStore
@State private var pendingHost = ""
@State private var pendingPort = ""
@State private var wcApiKey = ""
@State private var connStatus: ConnStatus = .untested
@State private var showAddProject = false
enum ConnStatus { case untested, testing, success, failure(String)
var label: String { switch self { case .untested: return "Not tested"; case .testing: return "Testing…"; case .success: return "Connected ✓"; case .failure(let m): return "Failed: \(m)" } }
var color: Color { switch self { case .success: return .green; case .failure: return .red; case .testing: return .secondary; default: return .secondary } }
static func ==(a: ConnStatus, b: ConnStatus) -> Bool {
switch (a,b) { case (.success,.success),(.untested,.untested),(.testing,.testing): return true; case (.failure(let x),.failure(let y)): return x==y; default: return false }
}
}
var body: some View {
NavigationStack {
Form {
// Daemon
Section {
HStack {
Label("Tailscale IP", systemImage:"network"); Spacer()
TextField("100.x.x.x", text:$pendingHost).multilineTextAlignment(.trailing).keyboardType(.numbersAndPunctuation).autocorrectionDisabled().autocapitalization(.none).frame(width:160).onChange(of:pendingHost){_,_ in connStatus = .untested}
}
HStack {
Label("Port", systemImage:"antenna.radiowaves.left.and.right"); Spacer()
TextField("8080", text:$pendingPort).multilineTextAlignment(.trailing).keyboardType(.numberPad).frame(width:80)
}
HStack { Text("Status").foregroundStyle(.secondary); Spacer(); Text(connStatus.label).foregroundStyle(connStatus.color).font(.subheadline) }
Button("Test Connection") { Task { await testConnection() } }.disabled(pendingHost.isEmpty)
Button("Save & Apply") { applyConnection() }.buttonStyle(.borderedProminent)
.disabled(pendingHost.isEmpty || connStatus != .success)
} header: { Text("Mac Daemon") }
// Projects
Section {
ForEach(projectStore.projects) { p in
VStack(alignment:.leading, spacing:2) {
Text(p.name).font(.body)
Text(p.rootPath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle)
Text("Scheme: \(p.scheme) · WC: \(p.workingCopyRepoName.isEmpty ? "not set" : p.workingCopyRepoName)").font(.caption2).foregroundStyle(.tertiary)
}.padding(.vertical,2)
}.onDelete { projectStore.remove(atOffsets:$0) }
Button { showAddProject = true } label: { Label("Add Project", systemImage:"plus.circle") }
} header: { Text("Projects") } footer: { Text("Mac path should be the directory containing project.yml or .xcodeproj") }
// Editor
Section("Editor Appearance") {
HStack {
Label("Font Size", systemImage:"textformat.size"); Spacer()
Stepper("\(Int(editorState.fontSize))pt", value:$editorState.fontSize, in:8...32, step:1).frame(width:160)
}
Picker(selection:$editorState.selectedTheme) {
ForEach(EditorThemeOption.allCases) { o in Text(o.displayName).tag(o) }
} label: { Label("Theme", systemImage:"paintbrush") }
Toggle(isOn:$editorState.showLineNumbers) { Label("Line Numbers", systemImage:"list.number") }
Toggle(isOn:$editorState.lineWrapping) { Label("Line Wrapping", systemImage:"text.word.spacing") }
HStack {
Label("Indent Width", systemImage:"arrow.right.to.line"); Spacer()
Picker("", selection:$editorState.indentWidth) {
Text("2 spaces").tag(2); Text("4 spaces").tag(4); Text("Tab").tag(0)
}.labelsHidden().pickerStyle(.menu)
}
}
// Build
Section("Build") {
HStack {
Label("Team ID", systemImage:"person.badge.key"); Spacer()
TextField("XXXXXXXXXX", text:$daemonConfig.developmentTeam).multilineTextAlignment(.trailing).autocorrectionDisabled().autocapitalization(.none).frame(width:120)
}
Toggle(isOn:$daemonConfig.allowProvisioningUpdates) { Label("Allow Provisioning Updates", systemImage:"checkmark.seal") }
Toggle(isOn:$daemonConfig.buildInRelease) { Label("Build in Release", systemImage:"bolt") }
}
// Working Copy
Section {
HStack {
Label("API Key", systemImage:"key.fill"); Spacer()
SecureField("Paste from Working Copy", text:$wcApiKey).multilineTextAlignment(.trailing).autocorrectionDisabled().autocapitalization(.none).frame(width:200)
.onChange(of:wcApiKey){_,k in UserDefaults.standard.set(k, forKey:"workingCopyAPIKey")}
}
let installed = UIApplication.shared.canOpenURL(URL(string:"working-copy://")!)
Label(installed ? "Working Copy detected ✓" : "Working Copy not installed",
systemImage: installed ? "checkmark.circle.fill" : "exclamationmark.triangle")
.foregroundStyle(installed ? .green : .orange).font(.caption)
Text("For local git over Tailscale, clone in Working Copy using:\nssh://user@100.x.x.x/path/to/repo.git")
.font(.caption).foregroundStyle(.secondary)
} header: { Text("Working Copy (Git)") }
// LSP
Section {
Toggle(isOn:$daemonConfig.lspEnabled) { Label("sourcekit-lsp Proxy", systemImage:"wand.and.stars") }
if daemonConfig.lspEnabled {
HStack {
Label("Status", systemImage:"circle.fill").foregroundStyle(daemonConfig.lspConnected ? Color.green : Color.red)
Spacer()
Text(daemonConfig.lspConnected ? "Running" : "Disconnected").foregroundStyle(.secondary)
}
}
} header: { Text("Language Server (LSP)") }
// About
Section("About") {
LabeledContent("Version", value:"1.0.0-alpha")
LabeledContent("Editor Engine", value:"Runestone 0.5.1 / Tree-sitter")
LabeledContent("Protocol", value:"REST + WebSocket")
}
}
.navigationTitle("Settings").navigationBarTitleDisplayMode(.inline)
.onAppear { pendingHost = daemonConfig.host; pendingPort = String(daemonConfig.port)
wcApiKey = UserDefaults.standard.string(forKey:"workingCopyAPIKey") ?? "" }
.sheet(isPresented:$showAddProject) { AddProjectView(projectStore:projectStore) }
}
}
private func testConnection() async {
connStatus = .testing
let host = pendingHost.trimmingCharacters(in:.whitespaces)
let port = Int(pendingPort) ?? 8080
guard let url = URL(string:"http://\(host):\(port)/health") else { connStatus = .failure("Invalid URL"); return }
var req = URLRequest(url:url); req.timeoutInterval = 4
do {
let (_,resp) = try await URLSession.shared.data(for:req)
connStatus = (resp as? HTTPURLResponse)?.statusCode == 200 ? .success : .failure("Unexpected response")
} catch { connStatus = .failure(error.localizedDescription) }
}
private func applyConnection() {
daemonConfig.host = pendingHost.trimmingCharacters(in:.whitespaces)
daemonConfig.port = Int(pendingPort) ?? 8080
}
}
// MARK: - Add Project View
struct AddProjectView: View {
@ObservedObject var projectStore: ProjectStore
@Environment(\.dismiss) var dismiss
@State private var name = ""; @State private var path = ""
@State private var scheme = ""; @State private var wcRepo = ""
var body: some View {
NavigationStack {
Form {
Section("Project Details") {
TextField("Name (e.g. NavidromePlayer)", text:$name)
TextField("/Users/dallas/Dev/NavidromePlayer/NavidromePlayer", text:$path).autocorrectionDisabled().autocapitalization(.none).keyboardType(.URL)
TextField("Xcode Scheme", text:$scheme).autocorrectionDisabled().autocapitalization(.none)
}
Section("Working Copy") {
TextField("Repo name in Working Copy (e.g. NavidromePlayer)", text:$wcRepo).autocorrectionDisabled().autocapitalization(.none)
} footer: { Text("Must match the display name in Working Copy's repo list.") }
}
.navigationTitle("Add Project").navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement:.cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement:.confirmationAction) {
Button("Add") {
projectStore.add(SavedProject(name: name.isEmpty ? scheme : name,
rootPath: path, scheme: scheme,
workingCopyRepoName: wcRepo))
dismiss()
}.disabled(path.isEmpty || scheme.isEmpty)
}
}
}.presentationDetents([.medium])
}
}