PadXcode-iPad/Settings/SettingsView.swift

315 lines
15 KiB
Swift
Raw Permalink Normal View History

2026-04-12 00:46:30 -07:00
import SwiftUI
import UIKit
struct SettingsView: View {
@EnvironmentObject var daemonConfig: DaemonConfiguration
2026-04-12 22:07:35 -07:00
@ObservedObject var editorState: EditorState
@ObservedObject var projectStore: ProjectStore
2026-04-12 00:46:30 -07:00
@State private var pendingHost = ""
@State private var pendingPort = ""
@State private var wcApiKey = ""
@State private var connStatus: ConnStatus = .untested
@State private var showAddProject = false
2026-04-12 22:07:35 -07:00
// Equatable conformance added so != operator works on line 39 and elsewhere.
enum ConnStatus: Equatable {
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
default: return .secondary
}
}
// Custom == required because .failure has an associated value.
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
}
2026-04-12 00:46:30 -07:00
}
}
var body: some View {
NavigationStack {
Form {
2026-04-12 22:07:35 -07:00
// Daemon
2026-04-12 00:46:30 -07:00
Section {
HStack {
2026-04-12 22:07:35 -07:00
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)
2026-04-12 00:46:30 -07:00
}
HStack {
2026-04-12 22:07:35 -07:00
Text("Status").foregroundStyle(.secondary)
Spacer()
Text(connStatus.label).foregroundStyle(connStatus.color).font(.subheadline)
2026-04-12 00:46:30 -07:00
}
2026-04-12 22:07:35 -07:00
Button("Test Connection") { Task { await testConnection() } }
.disabled(pendingHost.isEmpty)
Button("Save & Apply") { applyConnection() }
.buttonStyle(.borderedProminent)
// != now works because ConnStatus conforms to Equatable.
2026-04-12 00:46:30 -07:00
.disabled(pendingHost.isEmpty || connStatus != .success)
} header: { Text("Mac Daemon") }
2026-04-12 22:07:35 -07:00
// Projects
2026-04-12 00:46:30 -07:00
Section {
ForEach(projectStore.projects) { p in
2026-04-12 22:07:35 -07:00
VStack(alignment: .leading, spacing: 2) {
2026-04-12 00:46:30 -07:00
Text(p.name).font(.body)
Text(p.rootPath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle)
2026-04-12 22:07:35 -07:00
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")
}
2026-04-12 00:46:30 -07:00
2026-04-12 22:07:35 -07:00
// Editor
2026-04-12 00:46:30 -07:00
Section("Editor Appearance") {
HStack {
2026-04-12 22:07:35 -07:00
Label("Font Size", systemImage: "textformat.size")
Spacer()
// fontSizeStored is the @AppStorage Double backing fontSize.
// Binding to the computed fontSize property is not possible;
// bind to the stored Double and display with Int cast.
Stepper(
"\(Int(editorState.fontSizeStored))pt",
value: $editorState.fontSizeStored,
in: 8.0...32.0,
step: 1.0
)
.frame(width: 160)
}
// selectedTheme is a computed property on EditorState
// cannot use $ directly; use an explicit Binding.
Picker(selection: Binding(
get: { editorState.selectedTheme },
set: { editorState.selectedTheme = $0 }
)) {
ForEach(EditorThemeOption.allCases) { o in
Text(o.displayName).tag(o)
}
} label: {
Label("Theme", systemImage: "paintbrush")
2026-04-12 00:46:30 -07:00
}
2026-04-12 22:07:35 -07:00
Toggle(isOn: $editorState.showLineNumbers) { Label("Line Numbers", systemImage: "list.number") }
Toggle(isOn: $editorState.lineWrapping) { Label("Line Wrapping", systemImage: "text.word.spacing") }
2026-04-12 00:46:30 -07:00
HStack {
2026-04-12 22:07:35 -07:00
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)
2026-04-12 00:46:30 -07:00
}
}
2026-04-12 22:07:35 -07:00
// Build
2026-04-12 00:46:30 -07:00
Section("Build") {
HStack {
2026-04-12 22:07:35 -07:00
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")
2026-04-12 00:46:30 -07:00
}
}
2026-04-12 22:07:35 -07:00
// Working Copy
2026-04-12 00:46:30 -07:00
Section {
HStack {
2026-04-12 22:07:35 -07:00
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")
}
2026-04-12 00:46:30 -07:00
}
2026-04-12 22:07:35 -07:00
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)
2026-04-12 00:46:30 -07:00
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)
2026-04-12 22:07:35 -07:00
} header: {
Text("Working Copy (Git)")
}
2026-04-12 00:46:30 -07:00
2026-04-12 22:07:35 -07:00
// LSP
2026-04-12 00:46:30 -07:00
Section {
2026-04-12 22:07:35 -07:00
Toggle(isOn: $daemonConfig.lspEnabled) {
Label("sourcekit-lsp Proxy", systemImage: "wand.and.stars")
}
2026-04-12 00:46:30 -07:00
if daemonConfig.lspEnabled {
HStack {
2026-04-12 22:07:35 -07:00
Label("Status", systemImage: "circle.fill")
.foregroundStyle(daemonConfig.lspConnected ? Color.green : Color.red)
2026-04-12 00:46:30 -07:00
Spacer()
2026-04-12 22:07:35 -07:00
Text(daemonConfig.lspConnected ? "Running" : "Disconnected")
.foregroundStyle(.secondary)
2026-04-12 00:46:30 -07:00
}
}
} header: { Text("Language Server (LSP)") }
2026-04-12 22:07:35 -07:00
// About
2026-04-12 00:46:30 -07:00
Section("About") {
2026-04-12 22:07:35 -07:00
LabeledContent("Version", value: "1.0.0-alpha")
LabeledContent("Editor Engine", value: "Runestone 0.5.1 / Tree-sitter")
LabeledContent("Protocol", value: "REST + WebSocket")
2026-04-12 00:46:30 -07:00
}
}
2026-04-12 22:07:35 -07:00
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
pendingHost = daemonConfig.host
pendingPort = String(daemonConfig.port)
wcApiKey = UserDefaults.standard.string(forKey: "workingCopyAPIKey") ?? ""
}
.sheet(isPresented: $showAddProject) {
AddProjectView(projectStore: projectStore)
}
2026-04-12 00:46:30 -07:00
}
}
2026-04-12 22:07:35 -07:00
// MARK: - Actions
2026-04-12 00:46:30 -07:00
private func testConnection() async {
connStatus = .testing
2026-04-12 22:07:35 -07:00
let host = pendingHost.trimmingCharacters(in: .whitespaces)
2026-04-12 00:46:30 -07:00
let port = Int(pendingPort) ?? 8080
2026-04-12 22:07:35 -07:00
guard let url = URL(string: "http://\(host):\(port)/health") else {
connStatus = .failure("Invalid URL"); return
}
var req = URLRequest(url: url); req.timeoutInterval = 4
2026-04-12 00:46:30 -07:00
do {
2026-04-12 22:07:35 -07:00
let (_, resp) = try await URLSession.shared.data(for: req)
connStatus = (resp as? HTTPURLResponse)?.statusCode == 200
? .success
: .failure("Unexpected status code")
} catch {
connStatus = .failure(error.localizedDescription)
}
2026-04-12 00:46:30 -07:00
}
private func applyConnection() {
2026-04-12 22:07:35 -07:00
daemonConfig.host = pendingHost.trimmingCharacters(in: .whitespaces)
2026-04-12 00:46:30 -07:00
daemonConfig.port = Int(pendingPort) ?? 8080
}
}
// MARK: - Add Project View
struct AddProjectView: View {
@ObservedObject var projectStore: ProjectStore
@Environment(\.dismiss) var dismiss
2026-04-12 22:07:35 -07:00
@State private var name = ""
@State private var path = ""
@State private var scheme = ""
@State private var wcRepo = ""
2026-04-12 00:46:30 -07:00
var body: some View {
NavigationStack {
Form {
Section("Project Details") {
2026-04-12 22:07:35 -07:00
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 with both header string AND footer requires the
// explicit header:/footer: trailing closure form.
Section {
TextField("Repo name in Working Copy (e.g. NavidromePlayer)", text: $wcRepo)
.autocorrectionDisabled()
.autocapitalization(.none)
} header: {
Text("Working Copy")
} footer: {
Text("Must match the display name in Working Copy's repo list.")
2026-04-12 00:46:30 -07:00
}
}
2026-04-12 22:07:35 -07:00
.navigationTitle("Add Project")
.navigationBarTitleDisplayMode(.inline)
2026-04-12 00:46:30 -07:00
.toolbar {
2026-04-12 22:07:35 -07:00
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
2026-04-12 00:46:30 -07:00
Button("Add") {
2026-04-12 22:07:35 -07:00
projectStore.add(SavedProject(
name: name.isEmpty ? scheme : name,
rootPath: path,
scheme: scheme,
workingCopyRepoName: wcRepo
))
2026-04-12 00:46:30 -07:00
dismiss()
2026-04-12 22:07:35 -07:00
}
.disabled(path.isEmpty || scheme.isEmpty)
2026-04-12 00:46:30 -07:00
}
}
2026-04-12 22:07:35 -07:00
}
.presentationDetents([.medium])
2026-04-12 00:46:30 -07:00
}
}