From 8c1a2b8f8c0474bc17f0190dbaefc13bffb9e76d Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 01:06:37 -0600 Subject: [PATCH 1/3] feat: implement Settings views with server management and QR pairing - SettingsView: server list with connect/disconnect, swipe-to-delete with confirmation dialog, test connection button with async feedback, QR scanner sheet, connection status badge, about section with version and GitHub link - AddServerView: dedicated form for manual server entry (name, host, port, token) with validation, replacing the limited alert-based approach Closes #86 --- .../Views/Settings/AddServerView.swift | 74 ++++++ .../Views/Settings/SettingsView.swift | 227 ++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift diff --git a/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift b/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift new file mode 100644 index 0000000..80b4643 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct AddServerView: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var name = "My Mac" + @State private var host = "" + @State private var port = "7700" + @State private var token = "" + + var body: some View { + NavigationStack { + Form { + Section("Server Details") { + TextField("Name", text: $name) + + TextField("Host (e.g., 192.168.1.100)", text: $host) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + + TextField("Port", text: $port) + .keyboardType(.numberPad) + } + + Section("Authentication") { + TextField("Token", text: $token) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .fontDesign(.monospaced) + } + + Section { + Button { + addServer() + } label: { + HStack { + Spacer() + Text("Add Server") + .fontWeight(.semibold) + Spacer() + } + } + .disabled(!isValid) + } + } + .navigationTitle("Add Server") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + } + + private var isValid: Bool { + !host.trimmingCharacters(in: .whitespaces).isEmpty + && !token.trimmingCharacters(in: .whitespaces).isEmpty + } + + private func addServer() { + let connection = ServerConnection( + name: name.trimmingCharacters(in: .whitespaces), + host: host.trimmingCharacters(in: .whitespaces), + port: Int(port) ?? 7700, + token: token.trimmingCharacters(in: .whitespaces) + ) + appState.addConnection(connection) + Task { await appState.connect(to: connection) } + dismiss() + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift b/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..f8c8269 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift @@ -0,0 +1,227 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(AppState.self) private var appState + + @State private var showAddManual = false + @State private var showQRScanner = false + @State private var deleteTarget: ServerConnection? + @State private var testResult: TestResult? + + private enum TestResult: Equatable { + case testing + case success + case failure(String) + } + + var body: some View { + NavigationStack { + List { + currentConnectionSection + savedServersSection + addServerSection + aboutSection + } + .navigationTitle("Settings") + .sheet(isPresented: $showQRScanner) { + QRScannerView { result in + handleQRScan(result) + } + } + .sheet(isPresented: $showAddManual) { + AddServerView() + } + .confirmationDialog( + "Delete Server", + isPresented: .init( + get: { deleteTarget != nil }, + set: { if !$0 { deleteTarget = nil } } + ), + presenting: deleteTarget + ) { server in + Button("Delete \"\(server.name)\"", role: .destructive) { + appState.removeConnection(server) + deleteTarget = nil + } + } message: { server in + Text("Remove \(server.name) (\(server.host):\(server.port))? This cannot be undone.") + } + } + } + + // MARK: - Sections + + @ViewBuilder + private var currentConnectionSection: some View { + Section("Current Connection") { + if let conn = appState.activeConnection { + HStack { + VStack(alignment: .leading) { + Text(conn.name) + .font(.headline) + Text("\(conn.host):\(conn.port)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + connectionStatusBadge + } + + testConnectionRow + + Button("Disconnect", role: .destructive) { + Task { @MainActor in + appState.disconnect() + } + } + } else { + Text("Not connected") + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var savedServersSection: some View { + Section("Saved Servers") { + ForEach(appState.connections) { conn in + Button { + Task { await appState.connect(to: conn) } + } label: { + HStack { + VStack(alignment: .leading) { + Text(conn.name) + Text("\(conn.host):\(conn.port)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if appState.activeConnection?.id == conn.id { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + } + .foregroundStyle(.primary) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button("Delete", role: .destructive) { + deleteTarget = conn + } + } + } + + if appState.connections.isEmpty { + Text("No saved servers") + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var addServerSection: some View { + Section("Add Server") { + Button { + showQRScanner = true + } label: { + Label("Scan QR Code", systemImage: "qrcode.viewfinder") + } + + Button { + showAddManual = true + } label: { + Label("Enter Manually", systemImage: "keyboard") + } + } + } + + @ViewBuilder + private var aboutSection: some View { + Section("About") { + LabeledContent("PPG Mobile", value: appVersion) + LabeledContent("Server Protocol", value: "v1") + + Link(destination: URL(string: "https://github.com/jongravois/ppg-cli")!) { + Label("GitHub Repository", systemImage: "link") + } + } + } + + // MARK: - Subviews + + @ViewBuilder + private var connectionStatusBadge: some View { + switch appState.connectionStatus { + case .connected: + Label("Connected", systemImage: "circle.fill") + .font(.caption) + .foregroundStyle(.green) + case .connecting: + ProgressView() + .controlSize(.small) + case .error(let msg): + Label(msg, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + .lineLimit(1) + case .disconnected: + Label("Disconnected", systemImage: "circle") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var testConnectionRow: some View { + Button { + testConnection() + } label: { + HStack { + Label("Test Connection", systemImage: "antenna.radiowaves.left.and.right") + Spacer() + switch testResult { + case .testing: + ProgressView() + .controlSize(.small) + case .success: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + case .failure: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + case nil: + EmptyView() + } + } + } + .disabled(testResult == .testing) + } + + // MARK: - Actions + + private func handleQRScan(_ result: String) { + if let conn = ServerConnection.fromQRCode(result) { + appState.addConnection(conn) + Task { await appState.connect(to: conn) } + } + showQRScanner = false + } + + private func testConnection() { + testResult = .testing + Task { + do { + _ = try await appState.client.fetchStatus() + testResult = .success + } catch { + testResult = .failure(error.localizedDescription) + } + // Auto-clear after 3 seconds + try? await Task.sleep(for: .seconds(3)) + testResult = nil + } + } + + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + } +} From 90d24c30c975608bc062fc85ede82eb6d229e272 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:50:52 -0600 Subject: [PATCH 2/3] fix: address code review findings for Settings views - P0: fix GitHub URL from jongravois to 2witstudios - P1: add @MainActor to testConnection Task, guard against race with Task.isCancelled check before clearing result - P2: use SecureField with show/hide toggle for token input - P2: show alert when QR scan fails to parse a valid ppg:// URL - P3: display error message text in test connection failure state --- .../Views/Settings/AddServerView.swift | 21 ++++++++++++++-- .../Views/Settings/SettingsView.swift | 24 ++++++++++++++----- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift b/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift index 80b4643..675e62a 100644 --- a/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift +++ b/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift @@ -8,6 +8,7 @@ struct AddServerView: View { @State private var host = "" @State private var port = "7700" @State private var token = "" + @State private var showToken = false var body: some View { NavigationStack { @@ -25,10 +26,26 @@ struct AddServerView: View { } Section("Authentication") { - TextField("Token", text: $token) + HStack { + Group { + if showToken { + TextField("Token", text: $token) + .fontDesign(.monospaced) + } else { + SecureField("Token", text: $token) + } + } .textInputAutocapitalization(.never) .autocorrectionDisabled() - .fontDesign(.monospaced) + + Button { + showToken.toggle() + } label: { + Image(systemName: showToken ? "eye.slash" : "eye") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } } Section { diff --git a/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift b/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift index f8c8269..19105fd 100644 --- a/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift +++ b/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift @@ -7,6 +7,7 @@ struct SettingsView: View { @State private var showQRScanner = false @State private var deleteTarget: ServerConnection? @State private var testResult: TestResult? + @State private var showQRError = false private enum TestResult: Equatable { case testing @@ -46,6 +47,11 @@ struct SettingsView: View { } message: { server in Text("Remove \(server.name) (\(server.host):\(server.port))? This cannot be undone.") } + .alert("Invalid QR Code", isPresented: $showQRError) { + Button("OK", role: .cancel) {} + } message: { + Text("The scanned code is not a valid ppg server. Expected format: ppg://host:port/token") + } } } @@ -140,7 +146,7 @@ struct SettingsView: View { LabeledContent("PPG Mobile", value: appVersion) LabeledContent("Server Protocol", value: "v1") - Link(destination: URL(string: "https://github.com/jongravois/ppg-cli")!) { + Link(destination: URL(string: "https://github.com/2witstudios/ppg-cli")!) { Label("GitHub Repository", systemImage: "link") } } @@ -185,9 +191,11 @@ struct SettingsView: View { case .success: Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) - case .failure: - Image(systemName: "xmark.circle.fill") + case .failure(let message): + Label(message, systemImage: "xmark.circle.fill") + .font(.caption) .foregroundStyle(.red) + .lineLimit(1) case nil: EmptyView() } @@ -199,16 +207,18 @@ struct SettingsView: View { // MARK: - Actions private func handleQRScan(_ result: String) { + showQRScanner = false if let conn = ServerConnection.fromQRCode(result) { appState.addConnection(conn) Task { await appState.connect(to: conn) } + } else { + showQRError = true } - showQRScanner = false } private func testConnection() { testResult = .testing - Task { + Task { @MainActor in do { _ = try await appState.client.fetchStatus() testResult = .success @@ -217,7 +227,9 @@ struct SettingsView: View { } // Auto-clear after 3 seconds try? await Task.sleep(for: .seconds(3)) - testResult = nil + if !Task.isCancelled { + testResult = nil + } } } From 75ec34a2509f69f10e4250b07f9172b8e75aa903 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:37:22 -0600 Subject: [PATCH 3/3] fix review findings in settings views and spawn test typing --- .../Views/Settings/AddServerView.swift | 36 +++++++++++++++---- .../Views/Settings/SettingsView.swift | 12 ++++--- src/commands/spawn.test.ts | 5 +-- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift b/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift index 675e62a..138b6bc 100644 --- a/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift +++ b/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift @@ -73,16 +73,40 @@ struct AddServerView: View { } private var isValid: Bool { - !host.trimmingCharacters(in: .whitespaces).isEmpty - && !token.trimmingCharacters(in: .whitespaces).isEmpty + !trimmedHost.isEmpty + && !trimmedToken.isEmpty + && parsedPort != nil + } + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var trimmedHost: String { + host.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var trimmedToken: String { + token.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var parsedPort: Int? { + guard + let value = Int(port.trimmingCharacters(in: .whitespacesAndNewlines)), + (1...65_535).contains(value) + else { + return nil + } + return value } private func addServer() { + guard let validatedPort = parsedPort else { return } let connection = ServerConnection( - name: name.trimmingCharacters(in: .whitespaces), - host: host.trimmingCharacters(in: .whitespaces), - port: Int(port) ?? 7700, - token: token.trimmingCharacters(in: .whitespaces) + name: trimmedName.isEmpty ? "My Mac" : trimmedName, + host: trimmedHost, + port: validatedPort, + token: trimmedToken ) appState.addConnection(connection) Task { await appState.connect(to: connection) } diff --git a/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift b/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift index 19105fd..8fa023a 100644 --- a/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift +++ b/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift @@ -9,6 +9,8 @@ struct SettingsView: View { @State private var testResult: TestResult? @State private var showQRError = false + private let repositoryURL = URL(string: "https://github.com/2witstudios/ppg-cli") + private enum TestResult: Equatable { case testing case success @@ -76,9 +78,7 @@ struct SettingsView: View { testConnectionRow Button("Disconnect", role: .destructive) { - Task { @MainActor in - appState.disconnect() - } + appState.disconnect() } } else { Text("Not connected") @@ -146,8 +146,10 @@ struct SettingsView: View { LabeledContent("PPG Mobile", value: appVersion) LabeledContent("Server Protocol", value: "v1") - Link(destination: URL(string: "https://github.com/2witstudios/ppg-cli")!) { - Label("GitHub Repository", systemImage: "link") + if let repositoryURL { + Link(destination: repositoryURL) { + Label("GitHub Repository", systemImage: "link") + } } } } diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..541d560 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -6,6 +6,7 @@ import { readManifest, resolveWorktree, updateManifest } from '../core/manifest. import { spawnAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { agentId, sessionId } from '../lib/id.js'; +import type { Manifest } from '../types/manifest.js'; import * as tmux from '../core/tmux.js'; vi.mock('node:fs/promises', async () => { @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); const mockedSplitPane = vi.mocked(tmux.splitPane); -function createManifest(tmuxWindow = '') { +function createManifest(tmuxWindow = ''): Manifest { return { version: 1 as const, projectRoot: '/tmp/repo', @@ -103,7 +104,7 @@ function createManifest(tmuxWindow = '') { } describe('spawnCommand', () => { - let manifestState = createManifest(); + let manifestState: Manifest = createManifest(); let nextAgent = 1; let nextSession = 1;