148 lines
4.6 KiB
Swift
148 lines
4.6 KiB
Swift
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<UInt8>) {
|
||
|
|
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) {}
|
||
|
|
}
|