Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions ios/PPGMobile/PPGMobile/Views/Settings/AddServerView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
241 changes: 241 additions & 0 deletions ios/PPGMobile/PPGMobile/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
5 changes: 3 additions & 2 deletions src/commands/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand All @@ -103,7 +104,7 @@ function createManifest(tmuxWindow = '') {
}

describe('spawnCommand', () => {
let manifestState = createManifest();
let manifestState: Manifest = createManifest();
let nextAgent = 1;
let nextSession = 1;

Expand Down