import AppKit import SwiftTerm import SwiftUI let exitedCode = 4000 let internalErrorCode = 1011 struct TerminalSpike: View { @Environment(Server.self) private var server @ViewState private var status = "" var body: some View { VStack(alignment: .leading) { HStack { Text("Shell in the task's worktree") Text(status) .foregroundStyle(.secondary) } if server.taskId.isEmpty { Text("No task selected.") } else if server.tasks.first(where: { $0.id == server.taskId })?.status != "active" { Text("The task is closed; its worktree is gone.") } else { TerminalHost(server: server, taskId: server.taskId, status: $status) } } } } struct TerminalHost: NSViewRepresentable { let server: Server let taskId: String @Binding var status: String func makeCoordinator() -> TerminalSession { TerminalSession(server: server, taskId: taskId) { s in status = s } } func makeNSView(context: Context) -> TerminalView { let t = TerminalView(frame: NSRect(x: 0, y: 0, width: 800, height: 400)) t.font = Fonts.ct(13) as NSFont t.terminalDelegate = context.coordinator context.coordinator.view = t DispatchQueue.main.async { t.window?.makeFirstResponder(t) context.coordinator.connect() } return t } func updateNSView(_ nsView: TerminalView, context: Context) {} static func dismantleNSView(_ nsView: TerminalView, coordinator: TerminalSession) { coordinator.close() } } @MainActor final class TerminalSession: NSObject, TerminalViewDelegate { let server: Server let taskId: String let show: (String) -> Void weak var view: TerminalView? private var ws: URLSessionWebSocketTask? init(server: Server, taskId: String, show: @escaping (String) -> Void) { self.server = server self.taskId = taskId self.show = show } func connect() { guard let view else { return } let t = view.terminalDimensions guard let http = try? server.url("/api/tasks/\(taskId)/terminal", [URLQueryItem(name: "cols", value: String(t.cols)), URLQueryItem(name: "rows", value: String(t.rows))]), var c = URLComponents(url: http, resolvingAgainstBaseURL: false) else { show("not connected") return } c.scheme = "wss" guard let wss = c.url else { show("bad terminal URL") return } let task = URLSession.shared.webSocketTask(with: wss) ws = task show("connecting…") task.resume() receive(task) } func close() { ws?.cancel(with: .normalClosure, reason: nil) ws = nil } private func receive(_ task: URLSessionWebSocketTask) { task.receive { result in Task { @MainActor in guard self.ws === task else { return } switch result { case .success(.data(let d)): self.show("") self.view?.feed(byteArray: [UInt8](d)[...]) self.receive(task) case .success(.string(let s)): self.view?.feed(text: s) self.receive(task) case .success: self.receive(task) case .failure(let error): let code = task.closeCode.rawValue let reason = task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? "" if code == exitedCode { self.show("shell ended: \(reason)") } else if code == internalErrorCode { self.show(reason) } else { self.show("disconnected (\(code) \(error.localizedDescription))") } self.ws = nil } } } } func send(source: TerminalView, data: ArraySlice) { ws?.send(.data(Data(data))) { _ in } } func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) { ws?.send(.string("{\"cols\":\(newCols),\"rows\":\(newRows)}")) { _ in } } func setTerminalTitle(source: TerminalView, title: String) {} func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} func scrolled(source: TerminalView, position: Double) {} func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} }