224 lines
9.3 KiB
Swift
224 lines
9.3 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 currentPiState: PiState? = nil
|
|
@State private var sessionName = ""
|
|
@State private var showSwitcher = false
|
|
@State private var showSettings = false // T-2.11
|
|
@State private var activeSessionId: String? = nil // T-2.6
|
|
@State private var cancellables = Set<AnyCancellable>()
|
|
@StateObject private var registry = SessionRegistry() // T-2.6
|
|
@EnvironmentObject var appState: AppState
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// ── Status bar ──────────────────────────────────────────
|
|
StatusBar(
|
|
sessionName: sessionName,
|
|
connectionStatus: statusText,
|
|
piState: $currentPiState,
|
|
onSwitcher: { showSwitcher = true },
|
|
onUnpair: { appState.unpair() },
|
|
onSettings: { showSettings = true } // T-2.11
|
|
)
|
|
|
|
// ── 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 initialBootstrap() }
|
|
.task { await registry.refresh(credential: credential) } // T-2.6
|
|
.onChange(of: activeSessionId) { _, newId in
|
|
guard let newId else { return }
|
|
// Avoid reconnect storm if id already matches the current connection.
|
|
if connection?.id == newId { return }
|
|
Task {
|
|
if let oldConn = connection { await oldConn.suspend() }
|
|
cancellables.removeAll()
|
|
connection = nil
|
|
currentPiState = nil
|
|
sessionName = ""
|
|
terminalVC.feed(data: Data("\u{1B}[H\u{1B}[2J".utf8))
|
|
await bootstrap(sessionId: newId)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSwitcher) { // T-2.6
|
|
SessionSwitcher(registry: registry, credential: credential) { session in
|
|
activeSessionId = session.id
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSettings) { // T-2.11
|
|
SettingsView(credential: credential)
|
|
.environmentObject(appState)
|
|
}
|
|
}
|
|
|
|
// MARK: - Bootstrap
|
|
|
|
private func initialBootstrap() async {
|
|
statusText = "Looking for sessions…"
|
|
do {
|
|
let sessionId = try await resolveSession()
|
|
await bootstrap(sessionId: sessionId)
|
|
} catch {
|
|
statusText = "Error: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
private func bootstrap(sessionId: String) async {
|
|
// Keep activeSessionId in sync. The .onChange handler guards against
|
|
// re-entry via `connection?.id == newId` check (connection is still nil here
|
|
// on first call, but we set it below before any further state change).
|
|
activeSessionId = sessionId
|
|
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)
|
|
|
|
// Wire stateEvents → currentPiState
|
|
conn.stateEvents
|
|
.compactMap { event -> PiState? in
|
|
if case .state(let s, _, _) = event { return s } else { return nil }
|
|
}
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { state in currentPiState = state }
|
|
.store(in: &cancellables)
|
|
|
|
// Wire stateEvents → sessionName
|
|
conn.stateEvents
|
|
.compactMap { event -> String? in
|
|
if case .sessionMeta(let name, _, _) = event { return name } else { return nil }
|
|
}
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { name in sessionName = name }
|
|
.store(in: &cancellables)
|
|
|
|
connection = conn
|
|
|
|
let lastSeq: UInt64? = conn.scrollback.sizeBytes > 0
|
|
? ResumeCursor().lastSeq(for: sessionId)
|
|
: nil
|
|
await conn.resume(from: lastSeq)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|