Files

373 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {}
}