315 lines
15 KiB
Swift
315 lines
15 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
|
|
|
|
// 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 .success: return "Connected checkmark"
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
// != now works because ConnStatus conforms to Equatable.
|
|
.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()
|
|
// 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")
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
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 status code")
|
|
} 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 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.")
|
|
}
|
|
}
|
|
.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])
|
|
}
|
|
}
|