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

504 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
typealias TaskChanges = Components.Schemas.TaskChanges
typealias FileChange = Components.Schemas.FileChange
private let statusLetter: [FileChange.StatusPayload: String] = [
.added: "A",
.modified: "M",
.deleted: "D",
.renamed: "R",
.typeChanged: "T",
]
enum ChangeMode {
case main, turn, step
}
private let emptyText: [ChangeMode: String] = [
.main: "No changes from the default branch.",
.turn: "No changes this turn.",
.step: "This step changed nothing.",
]
@MainActor
private enum ChangesMemory {
static var open: Set<String> = []
static var range = ChangeMode.main
static var pinRange = ChangeMode.step
}
func untilCancelled() async {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(3600))
}
}
@MainActor
@Observable
final class ChangesModel {
let store: Store
let compact: Bool
private(set) var changes: TaskChanges?
private(set) var error = ""
private(set) var diffs: [String: Result<String, ApiError>] = [:]
private(set) var open = ChangesMemory.open
private(set) var range = ChangesMemory.range
private(set) var pinRange = ChangesMemory.pinRange
@ObservationIgnored private var loading = false
@ObservationIgnored private var again = false
@ObservationIgnored private var timer: Task<Void, Never>?
@ObservationIgnored private var loadedSince: String?
@ObservationIgnored private var target = 0
@ObservationIgnored private var fetching: Set<String> = []
init(_ store: Store, compact: Bool) {
self.store = store
self.compact = compact
}
var pinned: Int? {
compact ? nil : store.pinned
}
var mode: ChangeMode {
compact ? .main : pinned != nil ? pinRange : range
}
var empty: String {
emptyText[mode]!
}
private func turnStart() -> String? {
store.transcript.last { $0.kind == "user.message" && $0.payload.value["head"] is String }?.payload.value["head"] as? String
}
func load() async {
guard let taskId = store.selectedTaskId else {
changes = nil
return
}
let snapshot = pinned
let since = snapshot == nil && mode == .turn ? turnStart() : nil
if snapshot == nil && mode == .turn && since == nil {
changes = nil
error = "No turn has started in this task yet."
return
}
if loading {
again = true
return
}
loading = true
let started = target
do {
let query: Operations.GetTaskChanges.Input.Query = snapshot == nil ? .init(since: since) : .init(snapshot: snapshot, step: pinRange == .step)
let fresh = try await store.api.client.getTaskChanges(path: .init(taskId: taskId), query: query).ok.body.json
if started == target {
loadedSince = since
if fresh.tree != changes?.tree || fresh.base != changes?.base {
diffs = [:]
}
changes = fresh
error = ""
}
} catch {
if started == target {
self.error = errorText(error)
}
}
loading = false
if again {
again = false
await load()
}
}
func retarget() {
target += 1
timer?.cancel()
changes = nil
diffs = [:]
error = ""
Task {
await load()
}
}
private func soon() {
timer?.cancel()
timer = Task {
try? await Task.sleep(for: .milliseconds(1500))
if !Task.isCancelled {
await load()
}
}
}
func stop() {
target += 1
timer?.cancel()
}
func transcript(_ fresh: [ByreEvent]) {
if pinned != nil {
return
}
if mode == .turn && turnStart() != loadedSince {
Task {
await load()
}
return
}
if fresh.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) {
soon()
}
}
func setRange(_ r: ChangeMode) {
if range == r {
return
}
range = r
ChangesMemory.range = r
retarget()
}
func setPinRange(_ r: ChangeMode) {
if pinRange == r {
return
}
pinRange = r
ChangesMemory.pinRange = r
retarget()
}
func setOpen(_ path: String, _ on: Bool) {
if on {
open.insert(path)
} else {
open.remove(path)
}
ChangesMemory.open = open
}
func ensureDiff(_ c: TaskChanges, _ f: FileChange) async {
guard let taskId = store.selectedTaskId, diffs[f.path] == nil, !fetching.contains(f.path) else {
return
}
fetching.insert(f.path)
defer {
fetching.remove(f.path)
}
let result: Result<String, ApiError>
do {
result = .success(try await store.api.client.getTaskDiff(path: .init(taskId: taskId), query: .init(base: c.base, tree: c.tree, path: f.path, oldPath: f.oldPath)).ok.body.json.patch)
} catch {
result = .failure(ApiError(status: 0, message: errorText(error)))
}
if changes?.base == c.base && changes?.tree == c.tree {
diffs[f.path] = result
}
}
}
struct ChangesView: View {
var compact = false
@Environment(Store.self) private var store
@ViewState private var model: ChangesModel?
var body: some View {
VStack(spacing: 0) {
if let model {
ChangesHead(model: model)
if !compact {
ChangesList(model: model)
}
}
}
.frame(maxHeight: compact ? nil : .infinity, alignment: .top)
.task(id: store.selectedTaskId) {
let m = model ?? ChangesModel(store, compact: compact)
model = m
m.retarget()
let off = store.selectedFeed?.appended.add { [weak m] events in
m?.transcript(events)
}
await untilCancelled()
off?()
m.stop()
}
.onChange(of: store.pinned) {
if !compact {
model?.retarget()
}
}
}
}
private struct ChangesHead: View {
let model: ChangesModel
@Environment(Store.self) private var store
var body: some View {
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
if let n = model.pinned {
SnapWhere(n: n)
}
summary
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.textSelection(.enabled)
}
Spacer(minLength: 0)
toggle
}
.modifier(HeaderBar())
}
@ViewBuilder
private var summary: some View {
if let c = model.changes {
let adds = c.files.reduce(0) { $0 + $1.additions }
let dels = c.files.reduce(0) { $0 + $1.deletions }
HStack(spacing: 0) {
Text("\(c.files.count) \(c.files.count == 1 ? "file" : "files") ")
Text("+\(adds)").foregroundStyle(Palette.ok)
Text(" ")
Text("−\(dels)").foregroundStyle(Palette.danger)
Text(" vs \(String(c.base.prefix(8)))").help(c.base)
if !model.error.isEmpty {
Text(" \(model.error)").foregroundStyle(Palette.danger)
}
}
} else {
Text(!model.error.isEmpty ? model.error : store.selectedTaskId != nil ? "Loading changes…" : "No task selected.")
}
}
@ViewBuilder
private var toggle: some View {
if !model.compact {
HStack(spacing: 4) {
if model.pinned != nil {
RangeTabButton(label: "Since main", active: model.pinRange == .main) {
model.setPinRange(.main)
}
RangeTabButton(label: "This step", active: model.pinRange == .step) {
model.setPinRange(.step)
}
RangeTabButton(label: "Live", active: false, help: "Back to the live worktree") {
store.pinSnapshot(nil)
}
} else {
RangeTabButton(label: "Since main", active: model.range == .main) {
model.setRange(.main)
}
RangeTabButton(label: "This turn", active: model.range == .turn) {
model.setRange(.turn)
}
}
}
.fixedSize()
}
}
}
private struct SnapWhere: View {
let n: Int
@Environment(Store.self) private var store
var body: some View {
let snaps = store.snapshots()
let snap = snaps.first { $0.n == n }
HStack(spacing: 0) {
Text("snapshot \(n)")
if let snap {
Text(" · \(snap.trigger.rawValue) · \(snap.createdAt.formatted(Date.FormatStyle().hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits)))")
}
Text(" · ")
step(n - 1, "← #\(n - 1)", snaps)
Text(" ")
step(n + 1, "#\(n + 1) →", snaps)
}
.font(Fonts.font(12))
}
private func step(_ to: Int, _ label: String, _ snaps: [Snapshot]) -> some View {
Button(label) {
store.pinSnapshot(to)
}
.buttonStyle(SmallLinkButtonStyle(color: Palette.text))
.disabled(!snaps.contains { $0.n == to })
}
}
private struct ChangesList: View {
let model: ChangesModel
@Environment(Store.self) private var store
var body: some View {
if store.selectedTaskId == nil {
EmptyText(text: "No task selected.")
} else if let c = model.changes {
if c.files.isEmpty {
EmptyText(text: model.empty)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 6) {
ForEach(c.files.sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending }, id: \.path) { f in
FileRow(model: model, changes: c, file: f)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
}
}
} else {
EmptyText(text: model.error.isEmpty ? "Loading…" : "")
}
}
}
private struct FileRow: View {
let model: ChangesModel
let changes: TaskChanges
let file: FileChange
var body: some View {
let open = model.open.contains(file.path)
VStack(alignment: .leading, spacing: 0) {
Button {
model.setOpen(file.path, !open)
} label: {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(statusLetter[file.status]!)
.font(Fonts.font(13, weight: 700))
.foregroundStyle(statusColor)
.help(file.status.rawValue.replacingOccurrences(of: "_", with: " "))
Text(file.oldPath.map { "\($0) → \(file.path)" } ?? file.path)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
Group {
if file.binary {
Text("binary").foregroundStyle(Palette.muted)
} else {
HStack(spacing: 0) {
Text("+\(file.additions)").foregroundStyle(Palette.ok)
Text(" ")
Text("−\(file.deletions)").foregroundStyle(Palette.danger)
}
}
}
.font(Fonts.font(11))
.fixedSize()
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if open {
Group {
switch model.diffs[file.path] {
case .none:
Text("loading…").foregroundStyle(Palette.muted).padding(.horizontal, 10).padding(.vertical, 6)
case .failure(let e):
Text(e.message).foregroundStyle(Palette.danger).padding(.horizontal, 10).padding(.vertical, 6)
case .success(let patch):
PatchView(patch: patch)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.raised)
.overlay(alignment: .top) {
Rectangle().fill(Palette.border).frame(height: 1)
}
.task(id: "\(changes.base) \(changes.tree)") {
await model.ensureDiff(changes, file)
}
}
}
.background(Palette.surface, in: RoundedRectangle(cornerRadius: 8))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Palette.border))
}
private var statusColor: Color {
switch file.status {
case .added: Palette.ok
case .deleted: Palette.danger
case .renamed, .modified: Palette.accent
case .typeChanged: Palette.muted
}
}
}
struct PatchView: View {
let patch: String
var body: some View {
let lines = patch.components(separatedBy: "\n")
if let start = lines.firstIndex(where: { $0.hasPrefix("@@") }) {
DiffLines(rows: lines[start...].map { DiffRow(text: $0, kind: PatchLine($0)) })
} else {
Text("No textual changes.")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
}
}
struct DiffRow {
let text: String
let kind: PatchLine
var lineno: String?
var mark = false
}
struct DiffLines: View {
let rows: [DiffRow]
@ViewState private var width: CGFloat = 0
var body: some View {
ScrollView(.horizontal) {
StretchColumn(minWidth: width) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
HStack(spacing: 0) {
if let n = row.lineno {
Text(n)
.foregroundStyle(Palette.muted)
.frame(minWidth: 30, alignment: .trailing)
.padding(.trailing, 8)
}
Text(verbatim: row.text.isEmpty ? " " : row.text)
.foregroundStyle(row.kind.color)
}
.fixedSize()
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(row.kind.background)
.overlay(alignment: .leading) {
if row.mark {
Rectangle().fill(Palette.accent).frame(width: 3)
}
}
}
}
.font(Fonts.font(11))
.padding(.vertical, 4)
.textSelection(.enabled)
}
.scrollBounceBehavior(.basedOnSize)
.onGeometryChange(for: CGFloat.self) { g in
g.size.width
} action: { w in
width = w
}
}
}