Files
byre/mac/Sources/Byre/Project.swift
T

307 lines
10 KiB
Swift

import ByreAPI
import SwiftUI
typealias Todo = Components.Schemas.Todo
typealias TodoItem = Components.Schemas.TodoItem
private let refreshKinds: Set<String> = ["task.merged", "project.created"]
struct ProjectView: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var todos: [String: Result<Todo, ApiError>] = [:]
@ViewState private var off: (() -> Void)?
var body: some View {
ScrollView {
if let p = store.focusedProject() {
ProjectBlock(project: p, todo: todos[p.id])
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
} else {
Muted(text: "No project selected.")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
off = store.events.add { ev in
if refreshKinds.contains(ev.kind), let projectId = ev.projectId {
load(projectId)
}
}
refresh()
}
.onDisappear {
off?()
off = nil
}
.onChange(of: store.connected) {
if store.connected {
refresh()
}
}
.onChange(of: store.projects.map(\.id)) {
refresh()
}
.onChange(of: store.focusedProject()?.id) {
if let p = store.focusedProject(), todos[p.id] == nil {
load(p.id)
}
}
}
private func refresh() {
if let p = store.focusedProject() {
load(p.id)
}
}
private func load(_ projectId: String) {
Task {
do {
todos[projectId] = .success(try await store.api.client.getTodo(path: .init(projectId: projectId)).ok.body.json)
} catch {
todos[projectId] = .failure(ApiError(status: 0, message: "Couldn't read TODO.md: \(errorText(error))"))
}
}
}
}
private struct ProjectBlock: View {
let project: Project
let todo: Result<Todo, ApiError>?
@Environment(Store.self) private var store
var body: some View {
let active = (store.tasks[project.id] ?? []).filter { $0.status == .active }
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(project.name).font(Fonts.font(13, weight: 700))
Text(project.defaultBranch).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
.padding(.top, 16)
.padding(.bottom, 6)
heading("Tasks")
TaskRows(active: active)
heading("TODO")
TodoBlock(project: project, todo: todo, active: active)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func heading(_ text: String) -> some View {
Text(text)
.font(Fonts.font(12, weight: 700))
.padding(.top, 14)
.padding(.bottom, 4)
}
}
private func nested(_ active: [ByreTask]) -> [(task: ByreTask, depth: Int)] {
let ids = Set(active.map(\.id))
var out: [(task: ByreTask, depth: Int)] = []
func add(_ task: ByreTask, _ depth: Int) {
out.append((task, depth))
for child in active.filter({ $0.parentTaskId == task.id }).reversed() {
add(child, depth + 1)
}
}
for task in active where task.parentTaskId == nil || !ids.contains(task.parentTaskId!) {
add(task, 0)
}
return out
}
private struct TaskRows: View {
let active: [ByreTask]
@Environment(Store.self) private var store
var body: some View {
if active.isEmpty {
Text("No active tasks.")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
} else {
VStack(alignment: .leading, spacing: 0) {
ForEach(nested(active), id: \.task.id) { row in
TaskRow(task: row.task, depth: row.depth)
}
}
.padding(.bottom, 8)
}
}
}
struct HoverRow<Content: View>: View {
var selected = false
var indent: CGFloat = 0
let action: () -> Void
@ViewBuilder let content: Content
@ViewState private var hover = false
var body: some View {
Button(action: action) {
HStack(spacing: 10) {
content
}
.padding(.leading, 10 + indent)
.padding(.trailing, 10)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selected ? Palette.accentTint : hover ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hover = $0 }
}
}
private struct TaskRow: View {
let task: ByreTask
let depth: Int
@Environment(Store.self) private var store
var body: some View {
let ts = store.taskState(task)
let color = taskPalette[task.color]
let count = store.inbox.filter { $0.taskId == task.id }.count
HoverRow(selected: task.id == store.selectedTaskId, indent: task.parentTaskId != nil ? 20 * CGFloat(depth - 1) : 0) {
if store.unreadSummary(task) {
store.openSummary(task)
} else {
store.focusTask(task.id)
}
} content: {
if let parent = task.parentTaskId {
Text("↳")
.foregroundStyle(Palette.muted)
.help("Subtask of \(store.findTask(parent)?.title ?? parent)")
}
StateMark(state: ts, color: color)
Text(task.title)
.foregroundStyle(color)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
if count > 0 {
Badge(count: count).help("inbox items")
}
if store.unreadSummary(task) {
Text("new summary")
.font(Fonts.font(11, weight: 700))
.foregroundStyle(Palette.accent)
.help("A turn summary you haven't read")
.accessibilityLabel("New summary from \(task.title), unread")
}
Text(ts.label)
.font(Fonts.font(11))
.foregroundStyle(ts.kind == .answer ? Palette.warn : ts.kind == .error ? Palette.danger : Palette.muted)
.help(ts.title)
}
}
}
private struct TodoBlock: View {
let project: Project
let todo: Result<Todo, ApiError>?
let active: [ByreTask]
var body: some View {
switch todo {
case .none:
row("Loading…")
case .failure(let e):
row(e.message, error: true)
case .success(let t):
if !t.exists {
row("No TODO.md on \(project.defaultBranch).")
} else if t.items.isEmpty {
row("TODO.md has no items.")
} else {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(t.items.enumerated()), id: \.offset) { i, item in
if !item.section.isEmpty && (i == 0 || t.items[i - 1].section != item.section) {
Text(item.section)
.font(Fonts.font(12, weight: 700))
.foregroundStyle(Palette.muted)
.padding(.top, 10)
.padding(.bottom, 4)
}
TodoRow(project: project, item: item, task: active.first { $0.todoItem == item.title })
}
}
}
}
}
private func row(_ text: String, error: Bool = false) -> some View {
Text(text)
.foregroundStyle(error ? Palette.danger : Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
}
private struct TodoRow: View {
let project: Project
let item: TodoItem
let task: ByreTask?
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 10) {
Text(item.title)
.foregroundStyle(task.map { taskPalette[$0.color] } ?? Palette.text)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
action
}
if !item.body.isEmpty {
MarkdownView(blocks: parseMarkdown(item.body), ctx: MDContext(color: Palette.muted))
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(task != nil && task?.id == store.selectedTaskId ? Palette.accentTint : .clear, in: RoundedRectangle(cornerRadius: 6))
}
@ViewBuilder
private var action: some View {
if let task {
let ts = store.taskState(task)
Button {
store.focusTask(task.id)
} label: {
HStack(spacing: 6) {
StateMark(state: ts, color: taskPalette[task.color])
Text(ts.label)
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.fixedSize()
} else {
let busy = store.todoStarting.contains("\(project.id)\n\(item.title)")
HStack(spacing: 6) {
Button(busy ? "Starting…" : "Start") {
store.startTodo(project.id, item.title, discuss: false, overlays)
}
.help("Start a task that gets straight to work")
Button("Discuss") {
store.startTodo(project.id, item.title, discuss: true, overlays)
}
.help("Start a task that discusses it with you in chat before building anything")
}
.buttonStyle(WebButtonStyle())
.disabled(busy)
.fixedSize()
}
}
}