130 lines
4.8 KiB
Swift
130 lines
4.8 KiB
Swift
// MainTerminalView.swift
|
|
// Wires SessionConnection → TerminalViewController + ModifierBar
|
|
// MVP: auto-picks the first session (or creates one named "pi")
|
|
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
struct MainTerminalView: View {
|
|
let credential: SidecarCredential
|
|
|
|
@State private var terminalVC = TerminalViewController()
|
|
@State private var connection: SessionConnection? = nil
|
|
@State private var statusText = "Connecting…"
|
|
@State private var cancellables = Set<AnyCancellable>()
|
|
@EnvironmentObject var appState: AppState
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// ── Status bar ──────────────────────────────────────────
|
|
HStack {
|
|
Circle()
|
|
.fill(connection != nil ? Color.green : Color.orange)
|
|
.frame(width: 8, height: 8)
|
|
Text(statusText)
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button("Unpair") {
|
|
appState.unpair()
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color(uiColor: .systemBackground))
|
|
|
|
Divider()
|
|
|
|
// ── Terminal ────────────────────────────────────────────
|
|
TerminalViewRepresentable(controller: terminalVC)
|
|
.ignoresSafeArea(edges: .bottom)
|
|
|
|
Divider()
|
|
|
|
// ── Modifier bar ────────────────────────────────────────
|
|
ModifierBar { frame in
|
|
Task {
|
|
try? await connection?.send(frame)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
.background(Color(uiColor: .secondarySystemBackground))
|
|
}
|
|
.task { await bootstrap() }
|
|
}
|
|
|
|
// MARK: - Bootstrap
|
|
|
|
private func bootstrap() async {
|
|
statusText = "Looking for sessions…"
|
|
do {
|
|
let sessionId = try await resolveSession()
|
|
statusText = "Connecting to \(sessionId)…"
|
|
let conn = SessionConnection(id: sessionId, credential: credential)
|
|
|
|
// Wire stream → terminal
|
|
conn.stream
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [terminalVC] data in
|
|
terminalVC.feed(data: data)
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
// Wire connection state → status text
|
|
conn.$connectionState
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { state in
|
|
switch state {
|
|
case .connected: statusText = "● \(sessionId)"
|
|
case .connecting: statusText = "Connecting…"
|
|
case .disconnected: statusText = "Disconnected"
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
connection = conn
|
|
await conn.resume(from: conn.scrollback.sizeBytes > 0
|
|
? ResumeCursor().lastSeq(for: sessionId)
|
|
: nil)
|
|
} catch {
|
|
statusText = "Error: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
// MARK: - Session resolution
|
|
|
|
/// Returns the first existing session id, or creates one named "pi".
|
|
private func resolveSession() async throws -> String {
|
|
let url = URL(string: "http://\(credential.host):\(credential.port)/sessions")!
|
|
var req = URLRequest(url: url)
|
|
req.setValue("Bearer \(credential.bearerToken)", forHTTPHeaderField: "Authorization")
|
|
|
|
let (data, _) = try await URLSession.shared.data(for: req)
|
|
|
|
struct SessionItem: Decodable { let id: String }
|
|
let sessions = (try? JSONDecoder().decode([SessionItem].self, from: data)) ?? []
|
|
|
|
if let first = sessions.first {
|
|
return first.id
|
|
}
|
|
|
|
// No sessions — create one
|
|
var createReq = URLRequest(url: url)
|
|
createReq.httpMethod = "POST"
|
|
createReq.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
createReq.setValue("Bearer \(credential.bearerToken)", forHTTPHeaderField: "Authorization")
|
|
createReq.httpBody = try? JSONEncoder().encode(["name": "pi"])
|
|
|
|
let (createData, _) = try await URLSession.shared.data(for: createReq)
|
|
struct Created: Decodable { let id: String }
|
|
if let created = try? JSONDecoder().decode(Created.self, from: createData) {
|
|
return created.id
|
|
}
|
|
|
|
throw URLError(.cannotParseResponse)
|
|
}
|
|
}
|