2026-10-16 22:23:31 -07:00
|
|
|
import AppKit
|
|
|
|
|
import SwiftTerm
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
let exitedCode = 4000
|
|
|
|
|
let internalErrorCode = 1011
|
2026-10-17 07:26:18 -07:00
|
|
|
private let retryAfter = Duration.seconds(2)
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-16 22:23:31 -07:00
|
|
|
|
|
|
|
|
struct TerminalHost: NSViewRepresentable {
|
2026-10-16 22:46:54 -07:00
|
|
|
let api: API
|
2026-10-16 22:23:31 -07:00
|
|
|
let taskId: String
|
2026-10-17 07:26:18 -07:00
|
|
|
let active: @MainActor () -> Bool
|
|
|
|
|
let show: @MainActor (String, Bool) -> Void
|
|
|
|
|
@Environment(\.colorScheme) private var scheme
|
2026-10-16 22:23:31 -07:00
|
|
|
|
|
|
|
|
func makeCoordinator() -> TerminalSession {
|
2026-10-17 07:26:18 -07:00
|
|
|
TerminalSession(api: api, taskId: taskId, active: active, show: show)
|
2026-10-16 22:23:31 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-10-17 07:26:18 -07:00
|
|
|
colour(t)
|
2026-10-16 22:23:31 -07:00
|
|
|
t.window?.makeFirstResponder(t)
|
|
|
|
|
context.coordinator.connect()
|
|
|
|
|
}
|
|
|
|
|
return t
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 07:26:18 -07:00
|
|
|
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)
|
|
|
|
|
}
|
2026-10-16 22:23:31 -07:00
|
|
|
|
|
|
|
|
static func dismantleNSView(_ nsView: TerminalView, coordinator: TerminalSession) {
|
|
|
|
|
coordinator.close()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@MainActor
|
2026-10-17 07:26:18 -07:00
|
|
|
final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocketDelegate {
|
2026-10-16 22:46:54 -07:00
|
|
|
let api: API
|
2026-10-16 22:23:31 -07:00
|
|
|
let taskId: String
|
2026-10-17 07:26:18 -07:00
|
|
|
let active: @MainActor () -> Bool
|
|
|
|
|
let show: @MainActor (String, Bool) -> Void
|
2026-10-16 22:23:31 -07:00
|
|
|
weak var view: TerminalView?
|
|
|
|
|
private var ws: URLSessionWebSocketTask?
|
2026-10-17 07:26:18 -07:00
|
|
|
private var retry: Task<Void, Never>?
|
|
|
|
|
private var done = false
|
2026-10-16 22:23:31 -07:00
|
|
|
|
2026-10-17 07:26:18 -07:00
|
|
|
init(api: API, taskId: String, active: @escaping @MainActor () -> Bool, show: @escaping @MainActor (String, Bool) -> Void) {
|
2026-10-16 22:46:54 -07:00
|
|
|
self.api = api
|
2026-10-16 22:23:31 -07:00
|
|
|
self.taskId = taskId
|
2026-10-17 07:26:18 -07:00
|
|
|
self.active = active
|
2026-10-16 22:23:31 -07:00
|
|
|
self.show = show
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func connect() {
|
2026-10-17 07:26:18 -07:00
|
|
|
guard let view, !done else {
|
2026-10-16 22:23:31 -07:00
|
|
|
return
|
|
|
|
|
}
|
2026-10-17 07:26:18 -07:00
|
|
|
retry?.cancel()
|
|
|
|
|
show("connecting…", false)
|
2026-10-16 22:23:31 -07:00
|
|
|
let t = view.terminalDimensions
|
2026-10-17 07:26:18 -07:00
|
|
|
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
|
2026-10-16 22:23:31 -07:00
|
|
|
ws = task
|
|
|
|
|
task.resume()
|
|
|
|
|
receive(task)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func close() {
|
2026-10-17 07:26:18 -07:00
|
|
|
done = true
|
|
|
|
|
retry?.cancel()
|
2026-10-16 22:23:31 -07:00
|
|
|
ws?.cancel(with: .normalClosure, reason: nil)
|
|
|
|
|
ws = nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 07:26:18 -07:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 22:23:31 -07:00
|
|
|
private func receive(_ task: URLSessionWebSocketTask) {
|
|
|
|
|
task.receive { result in
|
|
|
|
|
Task { @MainActor in
|
2026-10-17 07:26:18 -07:00
|
|
|
guard self.ws === task, !self.done else {
|
2026-10-16 22:23:31 -07:00
|
|
|
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)
|
2026-10-17 07:26:18 -07:00
|
|
|
case .failure:
|
|
|
|
|
self.closed(task)
|
2026-10-16 22:23:31 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 07:26:18 -07:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 22:23:31 -07:00
|
|
|
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) {}
|
|
|
|
|
}
|