194 lines
7.6 KiB
Swift
194 lines
7.6 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
|
||
@StateObject private var registry = SessionRegistry()
|
||
@State private var showSwitcher = false
|
||
@State private var activeSessionId: String? = nil
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
// ── Status bar ──────────────────────────────────────────
|
||
HStack {
|
||
Text(statusText)
|
||
.font(.caption.monospaced())
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button { showSwitcher = true } label: {
|
||
Image(systemName: "list.bullet")
|
||
}
|
||
.font(.caption)
|
||
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() }
|
||
.task { await registry.refresh(credential: credential) }
|
||
.sheet(isPresented: $showSwitcher) {
|
||
SessionSwitcher(
|
||
registry: registry,
|
||
credential: credential,
|
||
onSelect: { session in
|
||
activeSessionId = session.id
|
||
showSwitcher = false
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
// 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 live ANSI stream → terminal
|
||
conn.stream
|
||
.receive(on: DispatchQueue.main)
|
||
.sink { [terminalVC] data in
|
||
terminalVC.feed(data: data)
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
// Wire snapshots → terminal (already contains ESC[H + ESC[2J prefix)
|
||
conn.snapshots
|
||
.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)
|
||
|
||
// Wire resize callback — fires on layout + font changes.
|
||
terminalVC.onResize = { [weak conn] cols, rows in
|
||
guard let conn else { return }
|
||
Task { try? await conn.send(.resize(cols: cols, rows: rows)) }
|
||
}
|
||
|
||
// Wire keyboard input from SwiftTerm → sidecar.
|
||
terminalVC.onInput = { [weak conn] data in
|
||
guard let conn,
|
||
let text = String(data: data, encoding: .utf8) else { return }
|
||
Task { try? await conn.send(.keys(data: text)) }
|
||
}
|
||
|
||
// On first connection:
|
||
// 1. Clear SwiftTerm immediately (removes stale content)
|
||
// 2. Send resize so tmux + shell know the real dimensions
|
||
// 3. Wait for SIGWINCH to propagate and shell to redraw
|
||
// 4. Snapshot the now-stable screen state
|
||
conn.$connectionState
|
||
.filter { $0 == .connected }
|
||
.first()
|
||
.receive(on: DispatchQueue.main)
|
||
.sink { [weak conn, terminalVC] _ in
|
||
// Clear immediately — don’t show stale/mismatched content.
|
||
terminalVC.feed(data: Data("\u{1B}[H\u{1B}[2J".utf8))
|
||
|
||
Task { @MainActor in
|
||
let (cols, rows) = terminalVC.terminalSize
|
||
if cols > 0 && rows > 0 {
|
||
try? await conn?.send(.resize(cols: cols, rows: rows))
|
||
}
|
||
// 600 ms: SIGWINCH → fish/bash redraws → tmux pane settles.
|
||
// SSH sessions need extra round-trip time.
|
||
try? await Task.sleep(nanoseconds: 600_000_000)
|
||
try? await conn?.send(.snapshotRequest)
|
||
}
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
connection = conn
|
||
|
||
let lastSeq: UInt64? = conn.scrollback.sizeBytes > 0
|
||
? ResumeCursor().lastSeq(for: sessionId)
|
||
: nil
|
||
await conn.resume(from: lastSeq)
|
||
} 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)
|
||
}
|
||
}
|