Port the remaining tabs to the macOS client
This commit is contained in:
+134
-25
@@ -4,16 +4,70 @@ import SwiftUI
|
||||
|
||||
let exitedCode = 4000
|
||||
let internalErrorCode = 1011
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalHost: NSViewRepresentable {
|
||||
let api: API
|
||||
let taskId: String
|
||||
@Binding var status: String
|
||||
let active: @MainActor () -> Bool
|
||||
let show: @MainActor (String, Bool) -> Void
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
func makeCoordinator() -> TerminalSession {
|
||||
TerminalSession(api: api, taskId: taskId) { s in
|
||||
status = s
|
||||
}
|
||||
TerminalSession(api: api, taskId: taskId, active: active, show: show)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> TerminalView {
|
||||
@@ -22,13 +76,34 @@ struct TerminalHost: NSViewRepresentable {
|
||||
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) {}
|
||||
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()
|
||||
@@ -36,46 +111,64 @@ struct TerminalHost: NSViewRepresentable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TerminalSession: NSObject, TerminalViewDelegate {
|
||||
final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocketDelegate {
|
||||
let api: API
|
||||
let taskId: String
|
||||
let show: (String) -> Void
|
||||
let active: @MainActor () -> Bool
|
||||
let show: @MainActor (String, Bool) -> Void
|
||||
weak var view: TerminalView?
|
||||
private var ws: URLSessionWebSocketTask?
|
||||
private var retry: Task<Void, Never>?
|
||||
private var done = false
|
||||
|
||||
init(api: API, taskId: String, show: @escaping (String) -> Void) {
|
||||
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 else {
|
||||
guard let view, !done else {
|
||||
return
|
||||
}
|
||||
retry?.cancel()
|
||||
show("connecting…", false)
|
||||
let t = view.terminalDimensions
|
||||
let wss = 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: wss)
|
||||
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
|
||||
show("connecting…")
|
||||
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 else {
|
||||
guard self.ws === task, !self.done 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)):
|
||||
@@ -83,22 +176,38 @@ final class TerminalSession: NSObject, TerminalViewDelegate {
|
||||
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
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user