578 lines
19 KiB
Swift
578 lines
19 KiB
Swift
import AppKit
|
|
import ByreAPI
|
|
import SwiftUI
|
|
|
|
enum TabBadge {
|
|
case none
|
|
case count(Int, String)
|
|
}
|
|
|
|
struct TabBadgeView: View {
|
|
let badge: TabBadge
|
|
|
|
var body: some View {
|
|
switch badge {
|
|
case .none:
|
|
EmptyView()
|
|
case .count(let n, let help):
|
|
Badge(count: n)
|
|
.help(help)
|
|
}
|
|
}
|
|
}
|
|
|
|
let tabLabels: [TaskTab: String] = [
|
|
.transcript: "Transcript",
|
|
.board: "Board",
|
|
.changes: "Changes",
|
|
.files: "Files",
|
|
.terminal: "Terminal",
|
|
.inbox: "Inbox",
|
|
.reviews: "Reviews",
|
|
.workflow: "Workflow",
|
|
.project: "Project",
|
|
]
|
|
|
|
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()
|
|
TaskPane()
|
|
.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
|
|
store.paneFocus.attach(window)
|
|
}
|
|
}
|
|
.onAppear {
|
|
let store = store
|
|
let overlays = overlays
|
|
overlays.install { key in
|
|
ctrlGo(key, store, overlays)
|
|
}
|
|
}
|
|
.onDisappear {
|
|
overlays.uninstall()
|
|
let store = store
|
|
Task {
|
|
await store.paneFocus.leave()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PaneTabButton: View {
|
|
let label: String
|
|
var active = false
|
|
var attention = false
|
|
var badge = TabBadge.none
|
|
let action: () -> Void
|
|
@Environment(\.paneAccent) private var accent
|
|
@ViewState private var hover = false
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 6) {
|
|
Text(label)
|
|
TabBadgeView(badge: badge)
|
|
}
|
|
.font(Fonts.font(13, weight: attention ? 700 : 400))
|
|
.foregroundStyle(hover ? Palette.text : attention ? Palette.warn : active ? Palette.text : Palette.muted)
|
|
.padding(.horizontal, 10)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 6)
|
|
.background(attention ? Palette.warnTint : .clear)
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle()
|
|
.fill(active ? accent : attention ? Palette.warn : .clear)
|
|
.frame(height: 2)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.fixedSize()
|
|
.onHover { hover = $0 }
|
|
}
|
|
}
|
|
|
|
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) {
|
|
PaneTabButton(label: "Lead", active: tab == .lead) {
|
|
overlays.leftTab = .lead
|
|
}
|
|
PaneTabButton(label: "Status", active: tab == .status) {
|
|
overlays.leftTab = .status
|
|
}
|
|
PaneTabButton(label: "Settings", active: tab == .settings) {
|
|
overlays.leftTab = .settings
|
|
}
|
|
Spacer(minLength: 0)
|
|
HStack(spacing: 10) {
|
|
if !store.connected {
|
|
Text("reconnecting…")
|
|
.foregroundStyle(Palette.danger)
|
|
.fixedSize()
|
|
}
|
|
Circle()
|
|
.fill(store.connected ? Palette.ok : Palette.danger)
|
|
.frame(width: 8, height: 8)
|
|
.help(store.connected ? "live" : "reconnecting")
|
|
Text(store.me?.displayName ?? "")
|
|
.foregroundStyle(Palette.muted)
|
|
.truncationMode(.tail)
|
|
}
|
|
.lineLimit(1)
|
|
}
|
|
.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 == .status {
|
|
StatusView()
|
|
} else if tab == .settings {
|
|
SettingsView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct TaskPane: View {
|
|
@Environment(Store.self) private var store
|
|
@Environment(Overlays.self) private var overlays
|
|
@ViewState private var seen: Set<String> = []
|
|
|
|
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()
|
|
TabStrip()
|
|
tabBody
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.environment(\.paneAccent, accent)
|
|
.environment(\.inRightPane, true)
|
|
.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
|
|
}
|
|
.onAppear {
|
|
seen = Set(store.inbox.map(\.id))
|
|
}
|
|
.onChange(of: store.inbox.map(\.id)) {
|
|
let fresh = store.inbox.filter { !seen.contains($0.id) }
|
|
for item in fresh {
|
|
seen.insert(item.id)
|
|
}
|
|
if store.focus.tab == .transcript && !store.paneFocus.drafting && fresh.contains(where: { $0.kind == .question && $0.taskId == store.selectedTaskId }) {
|
|
store.focusTab(.inbox)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var tabBody: some View {
|
|
switch store.focus.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()
|
|
case .terminal:
|
|
TerminalTab()
|
|
case .inbox:
|
|
InboxView()
|
|
case .reviews:
|
|
ReviewsView()
|
|
case .workflow:
|
|
WorkflowView()
|
|
case .project:
|
|
ProjectView()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct TaskBar: View {
|
|
@Environment(Store.self) private var store
|
|
@Environment(Overlays.self) private var overlays
|
|
@Environment(\.paneAccent) private var accent
|
|
@ViewState private var previews: [Components.Schemas.Preview] = []
|
|
@ViewState private var target = 0
|
|
@ViewState private var off: (() -> Void)?
|
|
|
|
var body: some View {
|
|
let task = store.selectedTaskId.flatMap { store.findTask($0) }
|
|
let project = store.focusedProject()
|
|
let label = task.map { "\(project?.name ?? "") ▸ \($0.title)" } ?? 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(previews, id: \.port) { p in
|
|
PreviewLink(preview: p, url: previewURL(store.api.origin, p.port))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.background {
|
|
ZStack {
|
|
Palette.bg
|
|
accent.opacity(0.12)
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle().fill(Palette.border).frame(height: 1)
|
|
}
|
|
.task(id: store.selectedTaskId) {
|
|
previews = []
|
|
await load()
|
|
}
|
|
.onAppear {
|
|
off = store.events.add { ev in
|
|
if ev.kind == "preview.exposed" && ev.taskId == store.selectedTaskId {
|
|
Task {
|
|
await load()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onDisappear {
|
|
off?()
|
|
off = nil
|
|
}
|
|
}
|
|
|
|
private func load() async {
|
|
target += 1
|
|
let started = target
|
|
guard let taskId = store.selectedTaskId else {
|
|
previews = []
|
|
return
|
|
}
|
|
do {
|
|
let list = try await store.api.client.listPreviews(path: .init(taskId: taskId)).ok.body.json
|
|
if started == target {
|
|
previews = list
|
|
}
|
|
} catch {
|
|
if started == target {
|
|
previews = []
|
|
}
|
|
log.error("list previews: \(errorText(error), privacy: .public)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PreviewLink: View {
|
|
let preview: Components.Schemas.Preview
|
|
let url: URL
|
|
@Environment(\.paneAccent) private var accent
|
|
@ViewState private var hover = false
|
|
|
|
var body: some View {
|
|
Link(destination: url) {
|
|
Text("\(preview.name) ↗")
|
|
.foregroundStyle(accent)
|
|
.underline(hover)
|
|
.lineLimit(1)
|
|
}
|
|
.help("Open \(preview.name) (port \(preview.port)) in a new window")
|
|
.onHover { hover = $0 }
|
|
}
|
|
}
|
|
|
|
private struct TabStrip: View {
|
|
@Environment(Store.self) private var store
|
|
@Environment(Overlays.self) private var overlays
|
|
@Environment(\.paneAccent) private var accent
|
|
@ViewState private var widths: [TaskTab: CGFloat] = [:]
|
|
@ViewState private var moreWidth: CGFloat = 0
|
|
@ViewState private var stripWidth: CGFloat = 0
|
|
@ViewState private var moreFrame: CGRect = .zero
|
|
|
|
private struct Info {
|
|
var order: [TaskTab]
|
|
var tab: TaskTab
|
|
var waiting: Int
|
|
var findings: Int
|
|
var reviewing: Bool
|
|
|
|
func count(_ t: TaskTab) -> Int {
|
|
t == .inbox ? waiting : t == .reviews && !reviewing ? findings : 0
|
|
}
|
|
|
|
func badge(_ t: TaskTab) -> TabBadge {
|
|
count(t) > 0 ? .count(count(t), t == .inbox ? "inbox items" : "open findings in the latest review") : .none
|
|
}
|
|
}
|
|
|
|
private func info() -> Info {
|
|
var first: [TaskTab] = []
|
|
for t in store.focus.tabs where taskTabs.contains(t) && !first.contains(t) {
|
|
first.append(t)
|
|
}
|
|
let tab = store.focus.tab
|
|
let waiting = store.inbox.filter { $0.taskId == store.selectedTaskId }.count
|
|
let transcript = store.transcript
|
|
let latest = latestReview(transcript)
|
|
let reviewing = latest?.state == .running
|
|
let findings = latest.map(findingCount) ?? 0
|
|
let hasReviews = transcript.contains(where: isReviewEvent)
|
|
let workflow = store.selectedTaskId.flatMap { store.findTask($0) }?.workflowRun != nil
|
|
let team = store.selectedTaskId.map { !store.team($0).isEmpty } ?? false
|
|
let order = (first + taskTabs.filter { !first.contains($0) }).filter { t in
|
|
if t == tab {
|
|
return true
|
|
}
|
|
switch t {
|
|
case .board: return team
|
|
case .inbox: return waiting > 0
|
|
case .reviews: return hasReviews
|
|
case .workflow: return workflow
|
|
default: return true
|
|
}
|
|
}
|
|
return Info(order: order, tab: tab, waiting: waiting, findings: findings, reviewing: reviewing)
|
|
}
|
|
|
|
private func kept(_ info: Info) -> [TaskTab] {
|
|
let gap: CGFloat = 4
|
|
let inner = stripWidth - 24
|
|
func total(_ ts: [TaskTab]) -> CGFloat {
|
|
ts.reduce(0) { $0 + (widths[$1] ?? 0) + gap }
|
|
}
|
|
let all = info.order
|
|
if stripWidth == 0 || total(all) <= inner {
|
|
return all
|
|
}
|
|
let budget = inner - moreWidth - gap
|
|
var kept: [TaskTab] = []
|
|
for t in [info.tab] + all.filter({ $0 != info.tab }) {
|
|
if !kept.isEmpty && total(kept + [t]) > budget {
|
|
break
|
|
}
|
|
kept.append(t)
|
|
}
|
|
return kept
|
|
}
|
|
|
|
var body: some View {
|
|
let info = info()
|
|
let kept = kept(info)
|
|
let hidden = info.order.filter { !kept.contains($0) }
|
|
let flagged = hidden.filter { info.count($0) > 0 }
|
|
HStack(spacing: 4) {
|
|
ForEach(info.order.filter { kept.contains($0) }, id: \.self) { t in
|
|
PaneTabButton(label: tabLabels[t]!, active: t == info.tab, attention: info.count(t) > 0, badge: info.badge(t)) {
|
|
store.focusTab(t)
|
|
}
|
|
}
|
|
if !hidden.isEmpty {
|
|
MoreButton(badges: flagged.map(info.badge)) {
|
|
overlays.menu = OverlayMenu(anchor: moreFrame, accent: accent, entries: hidden.map { t in
|
|
MenuEntry(id: t.rawValue, label: tabLabels[t]!, badge: info.badge(t)) {
|
|
store.focusTab(t)
|
|
}
|
|
})
|
|
}
|
|
.onGeometryChange(for: CGRect.self) { g in
|
|
g.frame(in: .named("window"))
|
|
} action: { frame in
|
|
moreFrame = frame
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.clipped()
|
|
.background {
|
|
HStack(spacing: 4) {
|
|
ForEach(info.order, id: \.self) { t in
|
|
PaneTabButton(label: tabLabels[t]!, active: t == info.tab, attention: info.count(t) > 0, badge: info.badge(t)) {}
|
|
.onGeometryChange(for: CGFloat.self) { g in
|
|
g.size.width
|
|
} action: { w in
|
|
widths[t] = w
|
|
}
|
|
}
|
|
MoreButton(badges: []) {}
|
|
.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
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle().fill(Palette.border).frame(height: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MoreButton: View {
|
|
let badges: [TabBadge]
|
|
let action: () -> Void
|
|
@ViewState private var hover = false
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 6) {
|
|
Text("More ▾")
|
|
ForEach(Array(badges.enumerated()), id: \.offset) { _, b in
|
|
TabBadgeView(badge: b)
|
|
}
|
|
}
|
|
.font(Fonts.font(13, weight: badges.isEmpty ? 400 : 700))
|
|
.foregroundStyle(hover ? Palette.text : badges.isEmpty ? Palette.muted : Palette.warn)
|
|
.padding(.horizontal, 10)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 6)
|
|
.background(badges.isEmpty ? .clear : Palette.warnTint)
|
|
.overlay(alignment: .bottom) {
|
|
Rectangle().fill(badges.isEmpty ? .clear : Palette.warn).frame(height: 2)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.fixedSize()
|
|
.help("More tabs")
|
|
.onHover { hover = $0 }
|
|
}
|
|
}
|
|
|
|
private let emailShape = Re(#"^[^@\s]+@[^@\s]+$"#)
|
|
|
|
private 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)
|
|
HStack(spacing: 6) {
|
|
Text("Set the identity agents commit as: ")
|
|
TextField("Git name", text: Binding(get: { gitName }, set: { name = $0 }))
|
|
.modifier(FieldStyle())
|
|
.frame(width: 220)
|
|
.onSubmit {
|
|
save(gitName, valid)
|
|
}
|
|
TextField("Git email", text: $email)
|
|
.modifier(FieldStyle())
|
|
.frame(width: 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))
|
|
}
|
|
}
|
|
}
|
|
}
|