Files
byre/mac/Sources/Byre/Terminal.swift
T
2026-10-18 00:31:46 -07:00

236 lines
7.6 KiB
Swift

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.ns(13)
t.changeScrollback(5000)
t.terminalDelegate = context.coordinator
context.coordinator.view = t
DispatchQueue.main.async {
unsafe t.window?.makeFirstResponder(t)
context.coordinator.connect()
}
return t
}
func updateNSView(_ nsView: TerminalView, context: Context) {
if context.coordinator.scheme == scheme {
return
}
context.coordinator.scheme = 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?
var scheme: ColorScheme?
private var ws: URLSessionWebSocketTask?
private var retry: Task<Void, Never>?
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<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) {}
}