diff --git a/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift b/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift new file mode 100644 index 0000000..138b6bc --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift @@ -0,0 +1,115 @@ +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 = "" + @State private var showToken = false + + 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") { + HStack { + Group { + if showToken { + TextField("Token", text: $token) + .fontDesign(.monospaced) + } else { + SecureField("Token", text: $token) + } + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button { + showToken.toggle() + } label: { + Image(systemName: showToken ? "eye.slash" : "eye") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + + 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 { + !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: trimmedName.isEmpty ? "My Mac" : trimmedName, + host: trimmedHost, + port: validatedPort, + token: trimmedToken + ) + 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..8fa023a --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift @@ -0,0 +1,241 @@ +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? + @State private var showQRError = false + + private let repositoryURL = URL(string: "https://github.com/2witstudios/ppg-cli") + + 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.") + } + .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") + } + } + } + + // 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) { + 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") + + if let repositoryURL { + Link(destination: repositoryURL) { + 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(let message): + Label(message, systemImage: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(.red) + .lineLimit(1) + case nil: + EmptyView() + } + } + } + .disabled(testResult == .testing) + } + + // 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 + } + } + + private func testConnection() { + testResult = .testing + Task { @MainActor in + 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)) + if !Task.isCancelled { + testResult = nil + } + } + } + + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + } +} 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;