Add multiple terminals per task and saved commands that start one
This commit is contained in:
@@ -42,6 +42,11 @@ struct SettingsView: View {
|
||||
ModelMenu(current: store.defaultModel, help: "Model for new tasks") { m in
|
||||
try await store.api.client.updateDefaultModel(body: .json(.init(model: m))).ok.body.json
|
||||
}
|
||||
heading("Saved commands")
|
||||
Text("Each is a button in the Terminal tab of every task on this server. It opens a new shell in the task's worktree and runs the command there.")
|
||||
.foregroundStyle(Palette.muted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
SavedCommands()
|
||||
heading("iCloud sync")
|
||||
SyncState()
|
||||
heading("This Mac's permissions")
|
||||
@@ -217,6 +222,140 @@ private struct PrefsBox: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SavedCommands: View {
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(Overlays.self) private var overlays
|
||||
@ViewState private var list: [SavedCommand] = []
|
||||
@ViewState private var editing: String?
|
||||
@ViewState private var name = ""
|
||||
@ViewState private var command = ""
|
||||
@ViewState private var off: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(list, id: \.id) { c in
|
||||
if editing == c.id {
|
||||
form(c.id)
|
||||
} else {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(c.name)
|
||||
Text(c.command)
|
||||
.font(Fonts.font(12))
|
||||
.foregroundStyle(Palette.muted)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Button("Edit") {
|
||||
editing = c.id
|
||||
name = c.name
|
||||
command = c.command
|
||||
}
|
||||
.buttonStyle(SmallLinkButtonStyle())
|
||||
Button("Delete") {
|
||||
delete(c)
|
||||
}
|
||||
.buttonStyle(SmallLinkButtonStyle(color: Palette.danger))
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle().fill(Palette.border).frame(height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if editing == nil {
|
||||
form(nil)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.onAppear {
|
||||
off = store.events.add { ev in
|
||||
if ev.kind == "command.saved" || ev.kind == "command.deleted" {
|
||||
load()
|
||||
}
|
||||
}
|
||||
load()
|
||||
}
|
||||
.onDisappear {
|
||||
off?()
|
||||
off = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func form(_ id: String?) -> some View {
|
||||
let valid = !name.trimmingCharacters(in: .whitespaces).isEmpty && !command.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
return HStack(spacing: 6) {
|
||||
TextField("Name", text: $name)
|
||||
.modifier(FieldStyle())
|
||||
.frame(width: 180)
|
||||
.onSubmit {
|
||||
save(id, valid)
|
||||
}
|
||||
TextField("Command", text: $command)
|
||||
.modifier(FieldStyle())
|
||||
.onSubmit {
|
||||
save(id, valid)
|
||||
}
|
||||
Button(id == nil ? "Add" : "Save") {
|
||||
save(id, valid)
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
.disabled(!valid)
|
||||
if id != nil {
|
||||
Button("Cancel") {
|
||||
editing = nil
|
||||
name = ""
|
||||
command = ""
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private func load() {
|
||||
Task {
|
||||
do {
|
||||
list = try await store.api.client.listSavedCommands().ok.body.json
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save(_ id: String?, _ valid: Bool) {
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
let body = Components.Schemas.SavedCommandSave(name: name, command: command)
|
||||
Task {
|
||||
do {
|
||||
if let id {
|
||||
_ = try await store.api.client.updateSavedCommand(path: .init(commandId: id), body: .json(body)).ok.body.json
|
||||
} else {
|
||||
_ = try await store.api.client.createSavedCommand(body: .json(body)).created.body.json
|
||||
}
|
||||
editing = nil
|
||||
name = ""
|
||||
command = ""
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func delete(_ c: SavedCommand) {
|
||||
Task {
|
||||
do {
|
||||
_ = try await store.api.client.deleteSavedCommand(path: .init(commandId: c.id)).noContent
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ModelMenu: View {
|
||||
let current: String
|
||||
let help: String
|
||||
|
||||
+166
-34
@@ -1,11 +1,14 @@
|
||||
import AppKit
|
||||
import ByreAPI
|
||||
import SwiftTerm
|
||||
import SwiftUI
|
||||
|
||||
let exitedCode = 4000
|
||||
let internalErrorCode = 1011
|
||||
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
|
||||
@@ -13,39 +16,76 @@ func terminalFocused() -> Bool {
|
||||
|
||||
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 exited = false
|
||||
@ViewState private var shell = 0
|
||||
@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) {
|
||||
Text("Shell in the task's worktree")
|
||||
.foregroundStyle(Palette.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
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)
|
||||
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)")
|
||||
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 {
|
||||
@@ -58,8 +98,97 @@ struct TerminalTab: View {
|
||||
.background(Palette.bg)
|
||||
}
|
||||
.onChange(of: "\(taskId ?? "") \(task?.status.rawValue ?? "")", initial: true) {
|
||||
list = nil
|
||||
selected = nil
|
||||
status = ""
|
||||
exited = false
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,12 +196,14 @@ struct TerminalTab: View {
|
||||
struct TerminalHost: NSViewRepresentable {
|
||||
let api: API
|
||||
let taskId: String
|
||||
let terminalId: String
|
||||
let active: @MainActor () -> Bool
|
||||
let show: @MainActor (String, Bool) -> Void
|
||||
let show: @MainActor (String) -> Void
|
||||
let lost: @MainActor () -> Void
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
func makeCoordinator() -> TerminalSession {
|
||||
TerminalSession(api: api, taskId: taskId, active: active, show: show)
|
||||
TerminalSession(api: api, taskId: taskId, terminalId: terminalId, active: active, show: show, lost: lost)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> TerminalView {
|
||||
@@ -127,19 +258,23 @@ struct TerminalHost: NSViewRepresentable {
|
||||
final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocketDelegate {
|
||||
let api: API
|
||||
let taskId: String
|
||||
let terminalId: String
|
||||
let active: @MainActor () -> Bool
|
||||
let show: @MainActor (String, Bool) -> Void
|
||||
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, active: @escaping @MainActor () -> Bool, show: @escaping @MainActor (String, Bool) -> Void) {
|
||||
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() {
|
||||
@@ -147,9 +282,9 @@ final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocket
|
||||
return
|
||||
}
|
||||
retry?.cancel()
|
||||
show("connecting…", false)
|
||||
show("connecting…")
|
||||
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 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
|
||||
@@ -171,7 +306,7 @@ final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocket
|
||||
return
|
||||
}
|
||||
self.view?.resetToInitialState()
|
||||
self.show("", false)
|
||||
self.show("")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,18 +337,15 @@ final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocket
|
||||
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)
|
||||
show("shell ended: \(reason)")
|
||||
return
|
||||
}
|
||||
if !active() {
|
||||
show("task closed", false)
|
||||
show("task closed")
|
||||
return
|
||||
}
|
||||
show("disconnected, reconnecting…", false)
|
||||
show("disconnected, reconnecting…")
|
||||
lost()
|
||||
retry = Task {
|
||||
try? await Task.sleep(for: retryAfter)
|
||||
if !Task.isCancelled {
|
||||
|
||||
Reference in New Issue
Block a user