582 lines
19 KiB
Swift
582 lines
19 KiB
Swift
import ByreAPI
|
|
import SwiftUI
|
|
|
|
#if os(macOS)
|
|
struct HalfSplit: Layout {
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
proposal.replacingUnspecifiedDimensions()
|
|
}
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
let half = bounds.width / 2
|
|
for (i, sub) in subviews.enumerated() {
|
|
sub.place(at: CGPoint(x: bounds.minX + half * CGFloat(i), y: bounds.minY), proposal: ProposedViewSize(width: half, height: bounds.height))
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MainView: View {
|
|
@Environment(Store.self) private var store
|
|
@Environment(Overlays.self) private var overlays
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
IdentityBanner()
|
|
HalfSplit {
|
|
LeftPane()
|
|
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
|
.clipped()
|
|
TaskPane()
|
|
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
|
.clipped()
|
|
.overlay(alignment: .leading) {
|
|
Rectangle().fill(Palette.border).frame(width: 1)
|
|
}
|
|
}
|
|
}
|
|
.overlay {
|
|
OverlayHost()
|
|
}
|
|
.coordinateSpace(.named("window"))
|
|
.environment(\.openURL, OpenURLAction { url in
|
|
openAppLink(url, store, overlays)
|
|
})
|
|
.background {
|
|
WindowReader { window in
|
|
overlays.window = window
|
|
store.paneFocus.attach(window)
|
|
}
|
|
}
|
|
.onAppear {
|
|
let store = store
|
|
let overlays = overlays
|
|
overlays.install { key in
|
|
ctrlGo(key, store, overlays)
|
|
} escape: {
|
|
overlays.cancelFilter() || store.closeSheet()
|
|
} list: { code in
|
|
listKey(code, store, overlays)
|
|
}
|
|
}
|
|
.onDisappear {
|
|
overlays.uninstall()
|
|
let store = store
|
|
Task {
|
|
await store.paneFocus.leave()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
struct PaneTabButton: View {
|
|
let label: String
|
|
var active = false
|
|
let action: () -> Void
|
|
@Environment(\.paneAccent) private var accent
|
|
@ViewState private var hover = false
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Text(label)
|
|
.font(Fonts.font(13))
|
|
.foregroundStyle(hover || active ? Palette.text : Palette.muted)
|
|
.padding(.horizontal, 10)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 6)
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle()
|
|
.fill(active ? accent : .clear)
|
|
.frame(height: 2)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.fixedSize()
|
|
.onHover { hover = $0 }
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
private struct LeftPane: View {
|
|
@Environment(Store.self) private var store
|
|
@Environment(Overlays.self) private var overlays
|
|
|
|
var body: some View {
|
|
let tab = overlays.leftTab
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 4) {
|
|
FoldingTabs(tabs: [(.lead, "Lead"), (.status, "Status"), (.notes, "Notes"), (.workflows, "Workflows"), (.server, "Server"), (.settings, "Settings")], active: tab) { t in
|
|
pick(t)
|
|
}
|
|
HStack(spacing: 10) {
|
|
if !store.connected {
|
|
Text("reconnecting…")
|
|
.foregroundStyle(Palette.muted)
|
|
.fixedSize()
|
|
}
|
|
Circle()
|
|
.fill(store.connected ? Palette.ok : Palette.muted)
|
|
.frame(width: 8, height: 8)
|
|
.help(store.connected ? "Live" : "Reconnecting")
|
|
}
|
|
.lineLimit(1)
|
|
.fixedSize()
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle().fill(Palette.border).frame(height: 1)
|
|
}
|
|
ZStack {
|
|
ChatView(kind: .lead)
|
|
.opacity(tab == .lead ? 1 : 0)
|
|
.allowsHitTesting(tab == .lead)
|
|
.accessibilityHidden(tab != .lead)
|
|
if tab == .notes {
|
|
NotesView()
|
|
} else if tab == .workflows {
|
|
WorkflowsView()
|
|
} else if tab == .status {
|
|
StatusView()
|
|
} else if tab == .server {
|
|
ServerView()
|
|
} else if tab == .settings {
|
|
SettingsView()
|
|
}
|
|
}
|
|
.overlay {
|
|
if let sheet = store.sheet, sheet.owner != .lead {
|
|
SheetHost(sheet: sheet)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func pick(_ tab: LeftTab) {
|
|
overlays.leftTab = tab
|
|
if store.sheet?.owner != .lead {
|
|
store.sheet = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct TaskPane: View {
|
|
@Environment(Store.self) private var store
|
|
@Environment(Overlays.self) private var overlays
|
|
|
|
var body: some View {
|
|
let selected = store.selectedTaskId.flatMap { store.findTask($0) }
|
|
let accent = selected.map { taskPalette[$0.color] } ?? Palette.accent
|
|
VStack(spacing: 0) {
|
|
TaskBar()
|
|
VStack(spacing: 0) {
|
|
if !overlays.taskList && store.selectedTaskId != nil {
|
|
TabStrip()
|
|
}
|
|
ZStack {
|
|
TaskTranscripts(visible: !overlays.taskList && store.selectedTaskId != nil && store.primaryTab == .transcript)
|
|
if overlays.taskList {
|
|
TaskList()
|
|
} else if store.selectedTaskId == nil {
|
|
ProjectView()
|
|
} else if store.primaryTab != .transcript {
|
|
TaskTabBody(tab: store.primaryTab)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
}
|
|
.overlay {
|
|
if let sheet = store.sheet, sheet.owner == .lead {
|
|
SheetHost(sheet: sheet)
|
|
}
|
|
}
|
|
}
|
|
.environment(\.paneAccent, accent)
|
|
.environment(\.inTask, true)
|
|
.onChange(of: store.reveals) {
|
|
overlays.taskList = false
|
|
}
|
|
.onChange(of: store.focus) {
|
|
overlays.taskList = false
|
|
}
|
|
.onGeometryChange(for: CGRect.self) { g in
|
|
g.frame(in: .global)
|
|
} action: { frame in
|
|
store.paneFocus.setFrame(frame)
|
|
}
|
|
.onGeometryChange(for: CGRect.self) { g in
|
|
g.frame(in: .named("window"))
|
|
} action: { frame in
|
|
overlays.paneFrame = frame
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// Keep active transcripts mounted at the pane's full size, including while another
|
|
// task or the project dashboard is showing. Switching only changes visibility.
|
|
struct TaskTranscripts: View {
|
|
var visible = true
|
|
@Environment(Store.self) private var store
|
|
|
|
private var ids: [String] {
|
|
var ids = store.activeTasks().map { $0.task.id }
|
|
if let selected = store.selectedTaskId, !ids.contains(selected) {
|
|
ids.append(selected)
|
|
}
|
|
return ids.sorted()
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
ForEach(ids, id: \.self) { id in
|
|
let shown = visible && store.selectedTaskId == id
|
|
ChatView(kind: .task(id), visible: shown)
|
|
.id("\(id)/\(store.pickedMember(id) ?? "")")
|
|
.environment(\.paneAccent, store.findTask(id).map { taskPalette[$0.color] } ?? Palette.accent)
|
|
.opacity(shown ? 1 : 0)
|
|
.allowsHitTesting(shown)
|
|
.accessibilityHidden(!shown)
|
|
.disabled(!shown)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TaskTabBody: View {
|
|
let tab: TaskTab
|
|
@Environment(Store.self) private var store
|
|
|
|
var body: some View {
|
|
switch tab {
|
|
case .transcript:
|
|
ChatView(kind: .task(store.selectedTaskId))
|
|
.id("\(store.selectedTaskId ?? "")/\(store.selectedTaskId.flatMap { store.pickedMember($0) } ?? "")")
|
|
case .board:
|
|
BoardView()
|
|
case .changes:
|
|
ChangesView()
|
|
case .files:
|
|
FilesView(root: store.selectedTaskId.map(FileRoot.task))
|
|
case .page:
|
|
if let shown = store.shownPagesHere() {
|
|
PageTabs(shown: shown)
|
|
}
|
|
case .captures:
|
|
CapturesView(captures: store.selectedTaskId.map { store.captures($0) } ?? [])
|
|
case .terminal:
|
|
TerminalTab()
|
|
case .workflow:
|
|
WorkflowView()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TaskBar: View {
|
|
@Environment(Store.self) private var store
|
|
@Environment(Overlays.self) private var overlays
|
|
@Environment(\.paneAccent) private var accent
|
|
|
|
var body: some View {
|
|
let task = store.selectedTaskId.flatMap { store.findTask($0) }
|
|
let project = store.focusedProject()
|
|
let label = overlays.taskList ? "All tasks" : task.map { "\(project?.name ?? "") ▸ \($0.name)" } ?? project?.name ?? "No task"
|
|
HStack(spacing: 0) {
|
|
Color.clear.frame(maxWidth: .infinity, maxHeight: 1)
|
|
Button {
|
|
overlays.openSwitcher(store)
|
|
} label: {
|
|
Text("\(label) ▾")
|
|
.font(Fonts.font(13, weight: 700))
|
|
.foregroundStyle(accent)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.help("Switch task (⌘K)")
|
|
.onGeometryChange(for: CGRect.self) { g in
|
|
g.frame(in: .named("window"))
|
|
} action: { frame in
|
|
overlays.pickerFrame = frame
|
|
}
|
|
.layoutPriority(1)
|
|
HStack(spacing: 12) {
|
|
Spacer(minLength: 0)
|
|
ForEach(store.selectedTaskId.map { store.forwards.previews($0) } ?? [], id: \.self) { p in
|
|
PreviewLink(preview: p)
|
|
}
|
|
#if os(macOS)
|
|
Button {
|
|
overlays.openSwitcher(store)
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
.font(Fonts.font(13, weight: 600))
|
|
.foregroundStyle(accent)
|
|
.padding(.vertical, 6)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("New task (⌘K)")
|
|
.accessibilityLabel("New task")
|
|
Button {
|
|
overlays.taskList.toggle()
|
|
} label: {
|
|
Image(systemName: "list.bullet")
|
|
.font(Fonts.font(13, weight: 600))
|
|
.foregroundStyle(accent)
|
|
.padding(.vertical, 6)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(overlays.taskList ? "Back to the task" : "Task list")
|
|
.accessibilityLabel("Task list")
|
|
#endif
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.background {
|
|
ZStack {
|
|
Palette.bg
|
|
accent.opacity(0.12)
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle().fill(Palette.border).frame(height: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PreviewLink: View {
|
|
let preview: ForwardedPreview
|
|
@Environment(Store.self) private var store
|
|
@Environment(\.paneAccent) private var accent
|
|
@ViewState private var hover = false
|
|
|
|
var body: some View {
|
|
Button {
|
|
store.openPage(.preview(taskId: preview.taskId, port: preview.port))
|
|
} label: {
|
|
Text(preview.name)
|
|
.foregroundStyle(accent)
|
|
.underline(hover)
|
|
.lineLimit(1)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Show \(preview.name) (port \(preview.port)) beside the transcript")
|
|
.onHover { hover = $0 }
|
|
}
|
|
}
|
|
|
|
struct TabStrip: View {
|
|
@Environment(Store.self) private var store
|
|
|
|
private struct Info {
|
|
var order: [TaskTab]
|
|
var tab: TaskTab
|
|
}
|
|
|
|
private func info() -> Info {
|
|
let tab = store.sheet.flatMap { $0.owner == .lead ? nil : $0.tab } ?? store.primaryTab
|
|
guard let taskId = store.selectedTaskId else {
|
|
return Info(order: [], tab: tab)
|
|
}
|
|
let run = store.findTask(taskId)?.workflow != nil
|
|
let team = !store.team(taskId).isEmpty
|
|
let order = TaskTab.allCases.filter { t in
|
|
if run {
|
|
return t == .workflow
|
|
}
|
|
if t == tab {
|
|
return true
|
|
}
|
|
switch t {
|
|
case .board: return team
|
|
case .page: return store.shownPages[.task(taskId)] != nil
|
|
case .captures: return !store.captures(taskId).isEmpty
|
|
case .workflow: return false
|
|
default: return true
|
|
}
|
|
}
|
|
return Info(order: order, tab: tab)
|
|
}
|
|
|
|
var body: some View {
|
|
let info = info()
|
|
FoldingTabs(tabs: info.order.map { ($0, $0.label) }, active: info.tab) { t in
|
|
store.openTab(t)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle().fill(Palette.border).frame(height: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct FoldingTabs<ID: Hashable>: View {
|
|
let tabs: [(id: ID, label: String)]
|
|
let active: ID
|
|
let pick: (ID) -> Void
|
|
@Environment(Overlays.self) private var overlays
|
|
@Environment(\.paneAccent) private var accent
|
|
@ViewState private var widths: [ID: CGFloat] = [:]
|
|
@ViewState private var moreWidth: CGFloat = 0
|
|
@ViewState private var stripWidth: CGFloat = 0
|
|
@ViewState private var moreFrame: CGRect = .zero
|
|
|
|
private func kept() -> [ID] {
|
|
let gap: CGFloat = 4
|
|
func total(_ ids: [ID]) -> CGFloat {
|
|
ids.reduce(0) { $0 + (widths[$1] ?? 0) + gap }
|
|
}
|
|
let all = tabs.map(\.id)
|
|
if stripWidth == 0 || total(all) <= stripWidth {
|
|
return all
|
|
}
|
|
let budget = stripWidth - moreWidth - gap
|
|
var kept: [ID] = []
|
|
for t in [active] + all.filter({ $0 != active }) {
|
|
if !kept.isEmpty && total(kept + [t]) > budget {
|
|
break
|
|
}
|
|
kept.append(t)
|
|
}
|
|
return kept
|
|
}
|
|
|
|
var body: some View {
|
|
let kept = kept()
|
|
let hidden = tabs.filter { !kept.contains($0.id) }
|
|
HStack(spacing: 4) {
|
|
ForEach(tabs.filter { kept.contains($0.id) }, id: \.id) { t in
|
|
PaneTabButton(label: t.label, active: t.id == active) {
|
|
pick(t.id)
|
|
}
|
|
}
|
|
if !hidden.isEmpty {
|
|
MoreButton {
|
|
overlays.menu = OverlayMenu(anchor: moreFrame, accent: accent, entries: hidden.map { t in
|
|
MenuEntry(id: t.label, label: t.label) {
|
|
pick(t.id)
|
|
}
|
|
})
|
|
}
|
|
.onGeometryChange(for: CGRect.self) { g in
|
|
g.frame(in: .named("window"))
|
|
} action: { frame in
|
|
moreFrame = frame
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
|
.clipped()
|
|
.background {
|
|
HStack(spacing: 4) {
|
|
ForEach(tabs, id: \.id) { t in
|
|
PaneTabButton(label: t.label, active: t.id == active) {}
|
|
.onGeometryChange(for: CGFloat.self) { g in
|
|
g.size.width
|
|
} action: { w in
|
|
widths[t.id] = w
|
|
}
|
|
}
|
|
MoreButton {}
|
|
.onGeometryChange(for: CGFloat.self) { g in
|
|
g.size.width
|
|
} action: { w in
|
|
moreWidth = w
|
|
}
|
|
}
|
|
.fixedSize()
|
|
.hidden()
|
|
}
|
|
.onGeometryChange(for: CGFloat.self) { g in
|
|
g.size.width
|
|
} action: { w in
|
|
stripWidth = w
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MoreButton: View {
|
|
let action: () -> Void
|
|
@ViewState private var hover = false
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Text("More ▾")
|
|
.font(Fonts.font(13))
|
|
.foregroundStyle(hover ? Palette.text : Palette.muted)
|
|
.padding(.horizontal, 10)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 6)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.fixedSize()
|
|
.help("More tabs")
|
|
.onHover { hover = $0 }
|
|
}
|
|
}
|
|
|
|
private let emailShape = Re(#"^[^@\s]+@[^@\s]+$"#)
|
|
|
|
struct IdentityBanner: View {
|
|
@Environment(Store.self) private var store
|
|
@Environment(Overlays.self) private var overlays
|
|
@ViewState private var name: String?
|
|
@ViewState private var email = ""
|
|
|
|
var body: some View {
|
|
if let me = store.me, me.gitName.isEmpty || me.gitEmail.isEmpty {
|
|
let gitName = name ?? me.displayName
|
|
let valid = !gitName.isEmpty && emailShape.test(email)
|
|
let row = touchOnly ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) : AnyLayout(HStackLayout(spacing: 6))
|
|
row {
|
|
Text("Name and email for the author line of agents' commits (not a git login or credentials):")
|
|
TextField("Git name", text: Binding(get: { gitName }, set: { name = $0 }))
|
|
.modifier(FieldStyle())
|
|
.frame(width: touchOnly ? nil : 220)
|
|
.onSubmit {
|
|
save(gitName, valid)
|
|
}
|
|
TextField("Git email", text: $email)
|
|
.modifier(FieldStyle())
|
|
.frame(width: touchOnly ? nil : 260)
|
|
.onSubmit {
|
|
save(gitName, valid)
|
|
}
|
|
Button("Save") {
|
|
save(gitName, valid)
|
|
}
|
|
.buttonStyle(WebButtonStyle())
|
|
.disabled(!valid)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(Palette.accentTint)
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle().fill(Palette.border).frame(height: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func save(_ gitName: String, _ valid: Bool) {
|
|
if !valid {
|
|
return
|
|
}
|
|
Task {
|
|
do {
|
|
store.me = try await store.api.client.updateMe(body: .json(.init(gitName: gitName, gitEmail: email))).ok.body.json
|
|
} catch {
|
|
overlays.showAlert(errorText(error))
|
|
}
|
|
}
|
|
}
|
|
}
|