2026-10-17 09:33:27 -07:00
|
|
|
import ByreAPI
|
2026-10-17 07:26:18 -07:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|