Add multiple terminals per task and saved commands that start one

This commit is contained in:
Ian Gulliver
2026-10-21 08:59:03 -07:00
parent fe2a2022c3
commit d062d7ccae
15 changed files with 1418 additions and 450 deletions
+139
View File
@@ -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