import ByreAPI import SwiftUI typealias Todo = Components.Schemas.Todo typealias TodoItem = Components.Schemas.TodoItem private let refreshKinds: Set = ["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] = [:] @ViewState private var off: (() -> Void)? @FocusState private var keys: Bool var body: some View { ScrollViewReader { proxy in 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.") } } .onChange(of: store.selectedTaskId) { if let id = store.selectedTaskId { proxy.scrollTo(id) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) .focusable() .focusEffectDisabled() .focused($keys) .onKeyPress(keys: [.upArrow, .downArrow]) { press in step(press.key == .downArrow ? 1 : -1) return .handled } .onKeyPress(.return) { guard let id = store.selectedTaskId, store.focusedProject() != nil else { return .ignored } store.focusTask(id) return .handled } .onAppear { if !(overlays.window?.firstResponder is NSTextView) { keys = true } 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 step(_ by: Int) { guard let p = store.focusedProject() else { return } let active = (store.tasks[p.id] ?? []).filter { $0.status == .active } let rows = nested(store, active.filter { !$0.background }).map(\.task.id) + active.filter(\.background).map(\.id) if rows.isEmpty { return } let next: Int if let i = rows.firstIndex(where: { $0 == store.selectedTaskId }) { next = min(max(i + by, 0), rows.count - 1) } else { next = by > 0 ? 0 : rows.count - 1 } store.focusTask(rows[next], .project) } 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 ProjectWorkflowMenu: View { let project: Project let audit: Bool @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var workflows: [String] = [] var body: some View { let current = audit ? project.auditWorkflow : project.reviewWorkflow Menu { Button("Off") { set("") } ForEach(workflows, id: \.self) { name in Button(name) { set(name) } } } label: { HStack(spacing: 6) { Text(current.isEmpty ? "Off" : current) Image(systemName: "chevron.up.chevron.down") .font(.system(size: 8, weight: .semibold)) } .font(Fonts.font(11)) .foregroundStyle(Palette.muted) .contentShape(Rectangle()) } .menuStyle(.button) .buttonStyle(.plain) .menuIndicator(.hidden) .fixedSize() .help(audit ? "The workflow byre runs in the background on \(project.defaultBranch)'s newest commit to check the whole codebase" : "The review workflow whose reviewers review every snapshot of this project's tasks") .task { do { workflows = try await store.api.client.listWorkflows().ok.body.json.map(\.name) } catch { workflows = [] } } } private func set(_ name: String) { Task { do { if audit { _ = try await store.api.client.setAuditWorkflow(path: .init(projectId: project.id), body: .json(.init(workflow: name))).ok.body.json } else { _ = try await store.api.client.setReviewWorkflow(path: .init(projectId: project.id), body: .json(.init(workflow: name))).ok.body.json } } catch { overlays.showAlert(errorText(error)) } } } } private struct ProjectBlock: View { let project: Project let todo: Result? @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) Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { GridRow { Text("Audit \(project.defaultBranch)") ProjectWorkflowMenu(project: project, audit: true) } GridRow { Text("Review tasks") ProjectWorkflowMenu(project: project, audit: false) } } .font(Fonts.font(11)) .foregroundStyle(Palette.muted) heading("Tasks") if active.isEmpty { Text("No active tasks.") .foregroundStyle(Palette.muted) .padding(.horizontal, 10) .padding(.vertical, 6) } TaskRows(active: active.filter { !$0.background }) ForEach(active.filter(\.background), id: \.id) { task in BackgroundRow(task: task) .id(task.id) } 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) } } @MainActor private func nested(_ store: Store, _ 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 store.byRank(active.filter({ $0.parentTaskId == task.id }).reversed()) { add(child, depth + 1) } } for task in store.byRank(active.filter { $0.parentTaskId == nil || !ids.contains($0.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 { VStack(alignment: .leading, spacing: 0) { ForEach(nested(store, active), id: \.task.id) { row in TaskRow(task: row.task, depth: row.depth) .id(row.task.id) } } .padding(.bottom, 8) } } } struct HoverRow: 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, max(0, 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) { store.focusTask(task.id) } content: { if let parent = task.parentTaskId { Text("↳") .foregroundStyle(Palette.muted) .help("Subtask of \(store.findTask(parent)?.name ?? parent)") } StateMark(state: ts, color: color) Text(task.name) .foregroundStyle(color) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) if count > 0 { Badge(count: count).help("open questions") } if ts.kind == .idle && 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.name), unread") } else { Text(ts.label) .font(Fonts.font(11)) .foregroundStyle(ts.needsYou ? Palette.warn : Palette.muted) .help(ts.title) } } } } private struct BackgroundRow: View { let task: ByreTask @Environment(Store.self) private var store var body: some View { let ts = store.taskState(task) HoverRow(selected: task.id == store.selectedTaskId) { store.focusTask(task.id) } content: { Text(task.name) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) Text(ts.label) .help(ts.title) } .font(Fonts.font(11)) .foregroundStyle(Palette.muted) .help("Runs in the background until it asks for your attention") } } private struct TodoBlock: View { let project: Project let todo: Result? 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, i == 0 ? 10 : 2) .padding(.bottom, 4) } TodoRow(project: project, item: item, task: active.first { $0.todoItem == item.title }) .padding(.bottom, i == t.items.count - 1 || t.items[i + 1].section != item.section ? 8 : 0) } } } } } 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() } } }