373 lines
12 KiB
Swift
373 lines
12 KiB
Swift
import AppKit
|
||
import ByreAPI
|
||
import SwiftTerm
|
||
import SwiftUI
|
||
|
||
let exitedCode = 4000
|
||
private let retryAfter = Duration.seconds(2)
|
||
|
||
typealias TaskTerminal = Components.Schemas.TaskTerminal
|
||
typealias SavedCommand = Components.Schemas.SavedCommand
|
||
|
||
@MainActor
|
||
func terminalFocused() -> Bool {
|
||
NSApp.keyWindow?.firstResponder is TerminalView
|
||
}
|
||
|
||
struct TerminalTab: View {
|
||
@Environment(Store.self) private var store
|
||
@Environment(Overlays.self) private var overlays
|
||
@ViewState private var list: [TaskTerminal]?
|
||
@ViewState private var commands: [SavedCommand] = []
|
||
@ViewState private var selected: String?
|
||
@ViewState private var status = ""
|
||
@ViewState private var failure: String?
|
||
@ViewState private var started = false
|
||
@ViewState private var loads = 0
|
||
@ViewState private var off: (() -> Void)?
|
||
|
||
var body: some View {
|
||
let taskId = store.selectedTaskId
|
||
let task = taskId.flatMap { store.findTask($0) }
|
||
let current = list?.first { $0.id == selected }?.id ?? list?.first?.id
|
||
VStack(spacing: 0) {
|
||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||
ForEach(list ?? [], id: \.id) { term in
|
||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||
RangeTabButton(label: term.name, active: term.id == current, help: "Show this terminal") {
|
||
selected = term.id
|
||
}
|
||
Button("×") {
|
||
close(term)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(Palette.muted)
|
||
.help("Close this terminal")
|
||
}
|
||
}
|
||
if task?.status == .active {
|
||
Button("+ Shell") {
|
||
start(nil)
|
||
}
|
||
.buttonStyle(SmallLinkButtonStyle())
|
||
.help("Open another shell in the task's worktree")
|
||
.padding(.leading, 8)
|
||
ForEach(commands, id: \.id) { c in
|
||
Button("▸ \(c.name)") {
|
||
start(c.id)
|
||
}
|
||
.buttonStyle(SmallLinkButtonStyle())
|
||
.help("Open a shell and run \(c.command)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Text(status)
|
||
.font(Fonts.font(11))
|
||
.foregroundStyle(Palette.muted)
|
||
.lineLimit(1)
|
||
}
|
||
.modifier(HeaderBar())
|
||
Group {
|
||
if let taskId, task?.status == .active {
|
||
if let current {
|
||
TerminalHost(api: store.api, taskId: taskId, terminalId: current, active: { [weak store] in
|
||
store?.findTask(taskId)?.status == .active
|
||
}, show: { text in
|
||
status = text
|
||
}, lost: {
|
||
load()
|
||
})
|
||
.id("\(taskId) \(current)")
|
||
} else if let failure {
|
||
EmptyText(text: failure, error: true)
|
||
} else if list != nil {
|
||
EmptyText(text: "No terminals are open in this task.")
|
||
}
|
||
} 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) {
|
||
list = nil
|
||
selected = nil
|
||
status = ""
|
||
failure = nil
|
||
started = false
|
||
load()
|
||
}
|
||
.onChange(of: store.connected) {
|
||
if store.connected {
|
||
load()
|
||
}
|
||
}
|
||
.onAppear {
|
||
off = store.events.add { ev in
|
||
if ev.kind == "terminals.changed" && ev.taskId == store.selectedTaskId {
|
||
load()
|
||
}
|
||
if ev.kind == "command.saved" || ev.kind == "command.deleted" {
|
||
loadCommands()
|
||
}
|
||
}
|
||
loadCommands()
|
||
}
|
||
.onDisappear {
|
||
off?()
|
||
off = nil
|
||
loads += 1
|
||
}
|
||
}
|
||
|
||
private func load() {
|
||
guard let taskId = store.selectedTaskId, store.findTask(taskId)?.status == .active else {
|
||
return
|
||
}
|
||
loads += 1
|
||
let n = loads
|
||
Task {
|
||
do {
|
||
let loaded = try await store.api.client.listTaskTerminals(path: .init(taskId: taskId)).ok.body.json
|
||
if n != loads {
|
||
return
|
||
}
|
||
list = loaded
|
||
failure = nil
|
||
if loaded.isEmpty && !started {
|
||
start(nil)
|
||
}
|
||
} catch {
|
||
failure = errorText(error)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func loadCommands() {
|
||
Task {
|
||
do {
|
||
commands = try await store.api.client.listSavedCommands().ok.body.json
|
||
} catch {
|
||
overlays.showAlert(errorText(error))
|
||
}
|
||
}
|
||
}
|
||
|
||
private func start(_ commandId: String?) {
|
||
guard let taskId = store.selectedTaskId else {
|
||
return
|
||
}
|
||
started = true
|
||
Task {
|
||
do {
|
||
let term = try await store.api.client.startTaskTerminal(path: .init(taskId: taskId), body: .json(.init(commandId: commandId))).created.body.json
|
||
if store.selectedTaskId == taskId {
|
||
selected = term.id
|
||
load()
|
||
}
|
||
} catch {
|
||
failure = errorText(error)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func close(_ term: TaskTerminal) {
|
||
guard let taskId = store.selectedTaskId else {
|
||
return
|
||
}
|
||
Task {
|
||
do {
|
||
_ = try await store.api.client.closeTaskTerminal(path: .init(taskId: taskId, terminalId: term.id)).noContent
|
||
} catch {
|
||
overlays.showAlert(errorText(error))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct TerminalHost: NSViewRepresentable {
|
||
let api: API
|
||
let taskId: String
|
||
let terminalId: String
|
||
let active: @MainActor () -> Bool
|
||
let show: @MainActor (String) -> Void
|
||
let lost: @MainActor () -> Void
|
||
@Environment(\.colorScheme) private var scheme
|
||
|
||
func makeCoordinator() -> TerminalSession {
|
||
TerminalSession(api: api, taskId: taskId, terminalId: terminalId, active: active, show: show, lost: lost)
|
||
}
|
||
|
||
func makeNSView(context: Context) -> TerminalView {
|
||
let t = TerminalView(frame: NSRect(x: 0, y: 0, width: 800, height: 400))
|
||
t.font = Fonts.ns(13, mono: true)
|
||
t.changeScrollback(5000)
|
||
t.setCursorStyle(.steadyBlock)
|
||
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) {
|
||
let font = Fonts.ns(13, mono: true)
|
||
if nsView.font !== font {
|
||
nsView.font = font
|
||
}
|
||
if context.coordinator.scheme == scheme {
|
||
return
|
||
}
|
||
context.coordinator.scheme = scheme
|
||
DispatchQueue.main.async {
|
||
color(nsView)
|
||
}
|
||
}
|
||
|
||
private func color(_ 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 terminalId: String
|
||
let active: @MainActor () -> Bool
|
||
let show: @MainActor (String) -> Void
|
||
let lost: @MainActor () -> 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, terminalId: String, active: @escaping @MainActor () -> Bool, show: @escaping @MainActor (String) -> Void, lost: @escaping @MainActor () -> Void) {
|
||
self.api = api
|
||
self.taskId = taskId
|
||
self.terminalId = terminalId
|
||
self.active = active
|
||
self.show = show
|
||
self.lost = lost
|
||
}
|
||
|
||
func connect() {
|
||
guard let view, !done else {
|
||
return
|
||
}
|
||
retry?.cancel()
|
||
show("connecting…")
|
||
let t = view.terminalDimensions
|
||
let url = api.url("/api/tasks/\(taskId)/terminals/\(terminalId)", [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("")
|
||
}
|
||
}
|
||
|
||
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)")
|
||
return
|
||
}
|
||
if !active() {
|
||
show("task closed")
|
||
return
|
||
}
|
||
show("disconnected, reconnecting…")
|
||
lost()
|
||
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) {}
|
||
}
|