import AppKit import SwiftTerm import SwiftUI let exitedCode = 4000 let internalErrorCode = 1011 private let retryAfter = Duration.seconds(2) @MainActor func terminalFocused() -> Bool { NSApp.keyWindow?.firstResponder is TerminalView } struct TerminalTab: View { @Environment(Store.self) private var store @ViewState private var status = "" @ViewState private var exited = false @ViewState private var shell = 0 var body: some View { let taskId = store.selectedTaskId let task = taskId.flatMap { store.findTask($0) } VStack(spacing: 0) { HStack(alignment: .firstTextBaseline, spacing: 12) { Text("Shell in the task's worktree") .foregroundStyle(Palette.muted) .frame(maxWidth: .infinity, alignment: .leading) Text(status) .font(Fonts.font(11)) .foregroundStyle(Palette.muted) .lineLimit(1) if exited { Button("New shell") { shell += 1 } .buttonStyle(WebButtonStyle()) } } .modifier(HeaderBar()) Group { if let taskId, task?.status == .active { TerminalHost(api: store.api, taskId: taskId, active: { [weak store] in store?.findTask(taskId)?.status == .active }, show: { text, ended in status = text exited = ended }) .id("\(taskId) \(shell)") } else if taskId == nil { EmptyText(text: "No task selected.") } else { EmptyText(text: "The task is closed; its worktree is gone.") } } .padding(.horizontal, 8) .padding(.vertical, 6) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Palette.bg) } .onChange(of: "\(taskId ?? "") \(task?.status.rawValue ?? "")", initial: true) { status = "" exited = false } } } struct TerminalHost: NSViewRepresentable { let api: API let taskId: String let active: @MainActor () -> Bool let show: @MainActor (String, Bool) -> Void @Environment(\.colorScheme) private var scheme func makeCoordinator() -> TerminalSession { TerminalSession(api: api, taskId: taskId, active: active, show: show) } 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 { colour(t) t.window?.makeFirstResponder(t) context.coordinator.connect() } return t } func updateNSView(_ nsView: TerminalView, context: Context) { _ = scheme DispatchQueue.main.async { colour(nsView) } } private func colour(_ t: TerminalView) { func resolved(_ c: SwiftUI.Color) -> NSColor { var out = NSColor(c) t.effectiveAppearance.performAsCurrentDrawingAppearance { out = NSColor(c).usingColorSpace(.sRGB) ?? NSColor(c) } return out } t.nativeBackgroundColor = resolved(Palette.bg) t.nativeForegroundColor = resolved(Palette.text) t.caretColor = resolved(Palette.accent) t.caretTextColor = resolved(Palette.bg) t.selectedTextBackgroundColor = resolved(Palette.accentTint) } static func dismantleNSView(_ nsView: TerminalView, coordinator: TerminalSession) { coordinator.close() } } @MainActor final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocketDelegate { let api: API let taskId: String let active: @MainActor () -> Bool let show: @MainActor (String, Bool) -> Void weak var view: TerminalView? private var ws: URLSessionWebSocketTask? private var retry: Task? private var done = false init(api: API, taskId: String, active: @escaping @MainActor () -> Bool, show: @escaping @MainActor (String, Bool) -> Void) { self.api = api self.taskId = taskId self.active = active self.show = show } func connect() { guard let view, !done else { return } retry?.cancel() show("connecting…", false) let t = view.terminalDimensions let url = api.url("/api/tasks/\(taskId)/terminal", [URLQueryItem(name: "cols", value: String(t.cols)), URLQueryItem(name: "rows", value: String(t.rows))], scheme: "wss") let task = api.session.webSocketTask(with: url) task.delegate = self ws = task task.resume() receive(task) } func close() { done = true retry?.cancel() ws?.cancel(with: .normalClosure, reason: nil) ws = nil } nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { let id = ObjectIdentifier(webSocketTask) Task { @MainActor in guard let ws = self.ws, ObjectIdentifier(ws) == id, !self.done else { return } self.view?.resetToInitialState() self.show("", false) } } private func receive(_ task: URLSessionWebSocketTask) { task.receive { result in Task { @MainActor in guard self.ws === task, !self.done else { return } switch result { case .success(.data(let d)): 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: self.closed(task) } } } } private func closed(_ task: URLSessionWebSocketTask) { ws = nil let code = task.closeCode.rawValue let reason = task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? "" if code == exitedCode { show("shell ended: \(reason)", true) return } if code == internalErrorCode { show(reason, true) return } if !active() { show("task closed", false) return } show("disconnected, reconnecting…", false) retry = Task { try? await Task.sleep(for: retryAfter) if !Task.isCancelled { connect() } } } 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) {} }