2026-10-17 09:33:27 -07:00
|
|
|
|
import ByreAPI
|
2026-10-17 07:26:18 -07:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 07:30:00 -07:00
|
|
|
|
func shows(_ a: LineAnchor) -> Bool {
|
|
|
|
|
|
guard open.contains(a.path), case .success(let patch)? = diffs[a.path] else {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
let lines = patch.components(separatedBy: "\n")
|
|
|
|
|
|
guard let start = lines.firstIndex(where: { $0.hasPrefix("@@") }) else {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return patchRows(lines[start...], a.path).contains { $0.anchor == a }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 07:26:18 -07:00
|
|
|
|
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
|
2026-10-18 07:30:00 -07:00
|
|
|
|
@Environment(Overlays.self) private var overlays
|
2026-10-17 07:26:18 -07:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-10-18 07:30:00 -07:00
|
|
|
|
if !model.compact, let taskId = store.selectedTaskId, let drafts = store.drafts {
|
|
|
|
|
|
let n = drafts.lineComments(taskId).filter { !trimmed($0.text).isEmpty }.count
|
|
|
|
|
|
if n > 0 {
|
|
|
|
|
|
Button("Send \(n) \(n == 1 ? "comment" : "comments")") {
|
|
|
|
|
|
sendComments(taskId, drafts)
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(WebButtonStyle(primary: true))
|
|
|
|
|
|
.fixedSize()
|
|
|
|
|
|
.help("Send the line comments to the task's agent as one message")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-17 07:26:18 -07:00
|
|
|
|
toggle
|
|
|
|
|
|
}
|
|
|
|
|
|
.modifier(HeaderBar())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 07:30:00 -07:00
|
|
|
|
private func sendComments(_ taskId: String, _ drafts: Drafts) {
|
|
|
|
|
|
let sent = drafts.takeLineComments(taskId)
|
|
|
|
|
|
Task {
|
|
|
|
|
|
do {
|
|
|
|
|
|
try await store.messageTask(taskId, commentsMessage(sent))
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
drafts.restoreLineComments(taskId, sent)
|
|
|
|
|
|
overlays.showAlert(errorText(error))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 07:26:18 -07:00
|
|
|
|
@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 {
|
2026-10-17 10:54:06 -07:00
|
|
|
|
Text(" · \(snap.trigger.rawValue) · \(clockText(snap.createdAt))")
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
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 {
|
2026-10-18 07:30:00 -07:00
|
|
|
|
if let taskId = store.selectedTaskId, let drafts = store.drafts {
|
|
|
|
|
|
let hidden = drafts.lineComments(taskId).filter { !model.shows($0.anchor) }
|
|
|
|
|
|
let files = model.changes?.files ?? []
|
|
|
|
|
|
if files.isEmpty && hidden.isEmpty {
|
|
|
|
|
|
EmptyText(text: model.changes != nil ? model.empty : model.error.isEmpty ? "Loading…" : "")
|
2026-10-17 07:26:18 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
ScrollView {
|
|
|
|
|
|
LazyVStack(alignment: .leading, spacing: 6) {
|
2026-10-18 07:30:00 -07:00
|
|
|
|
if !hidden.isEmpty {
|
|
|
|
|
|
HiddenComments(drafts: drafts, taskId: taskId, comments: hidden)
|
|
|
|
|
|
}
|
|
|
|
|
|
if let c = model.changes {
|
|
|
|
|
|
ForEach(c.files.sorted { $0.path.localizedCompare($1.path) == .orderedAscending }, id: \.path) { f in
|
|
|
|
|
|
FileRow(model: model, changes: c, file: f)
|
|
|
|
|
|
}
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.padding(.horizontal, 16)
|
|
|
|
|
|
.padding(.top, 8)
|
|
|
|
|
|
.padding(.bottom, 16)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-10-18 07:30:00 -07:00
|
|
|
|
EmptyText(text: "No task selected.")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private struct HiddenComments: View {
|
|
|
|
|
|
let drafts: Drafts
|
|
|
|
|
|
let taskId: String
|
|
|
|
|
|
let comments: [LineComment]
|
|
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
|
|
|
|
Text("Comments on lines not in the diff shown")
|
|
|
|
|
|
.font(Fonts.font(11))
|
|
|
|
|
|
.foregroundStyle(Palette.muted)
|
|
|
|
|
|
ForEach(comments, id: \.anchor) { c in
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
|
|
|
|
Text("\(c.anchor.path):\(c.anchor.line)\(c.anchor.old ? " (removed line)" : "")")
|
|
|
|
|
|
.font(Fonts.font(11))
|
|
|
|
|
|
.foregroundStyle(Palette.muted)
|
|
|
|
|
|
Text(verbatim: c.anchor.quote)
|
|
|
|
|
|
.font(Fonts.font(11))
|
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
|
.truncationMode(.tail)
|
|
|
|
|
|
LineCommentBox(drafts: drafts, taskId: taskId, anchor: c.anchor)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
2026-10-18 07:30:00 -07:00
|
|
|
|
.padding(.bottom, 6)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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):
|
2026-10-18 07:30:00 -07:00
|
|
|
|
PatchView(patch: patch, path: file.path, taskId: model.store.selectedTaskId)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 07:30:00 -07:00
|
|
|
|
private let hunkHead = Re(#"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@"#)
|
|
|
|
|
|
|
|
|
|
|
|
private func patchRows(_ lines: ArraySlice<String>, _ path: String) -> [DiffRow] {
|
|
|
|
|
|
var old = 0
|
|
|
|
|
|
var new = 0
|
|
|
|
|
|
return lines.map { l in
|
|
|
|
|
|
var row = DiffRow(text: l, kind: PatchLine(l))
|
|
|
|
|
|
switch row.kind {
|
|
|
|
|
|
case .hunk:
|
|
|
|
|
|
if let m = hunkHead.exec(l) {
|
|
|
|
|
|
old = Int(m[1]!) ?? 0
|
|
|
|
|
|
new = Int(m[2]!) ?? 0
|
|
|
|
|
|
}
|
|
|
|
|
|
case .add:
|
|
|
|
|
|
row.anchor = LineAnchor(path: path, line: new, old: false, quote: l)
|
|
|
|
|
|
new += 1
|
|
|
|
|
|
case .del:
|
|
|
|
|
|
row.anchor = LineAnchor(path: path, line: old, old: true, quote: l)
|
|
|
|
|
|
old += 1
|
|
|
|
|
|
case .ctx:
|
|
|
|
|
|
if l.hasPrefix(" ") {
|
|
|
|
|
|
row.anchor = LineAnchor(path: path, line: new, old: false, quote: l)
|
|
|
|
|
|
old += 1
|
|
|
|
|
|
new += 1
|
|
|
|
|
|
}
|
|
|
|
|
|
case .meta:
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
return row
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func commentsMessage(_ comments: [LineComment]) -> String {
|
|
|
|
|
|
comments.map { c in
|
|
|
|
|
|
"`\(c.anchor.path):\(c.anchor.line)`\(c.anchor.old ? " (removed line)" : "")\n> \(c.anchor.quote)\n\n\(trimmed(c.text))"
|
|
|
|
|
|
}.joined(separator: "\n\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 07:26:18 -07:00
|
|
|
|
struct PatchView: View {
|
|
|
|
|
|
let patch: String
|
2026-10-18 07:30:00 -07:00
|
|
|
|
let path: String
|
|
|
|
|
|
let taskId: String?
|
2026-10-17 07:26:18 -07:00
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
|
let lines = patch.components(separatedBy: "\n")
|
|
|
|
|
|
if let start = lines.firstIndex(where: { $0.hasPrefix("@@") }) {
|
2026-10-18 07:30:00 -07:00
|
|
|
|
DiffLines(rows: patchRows(lines[start...], path), taskId: taskId)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
} 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
|
2026-10-18 07:30:00 -07:00
|
|
|
|
var anchor: LineAnchor?
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct DiffLines: View {
|
|
|
|
|
|
let rows: [DiffRow]
|
2026-10-18 07:30:00 -07:00
|
|
|
|
var taskId: String?
|
|
|
|
|
|
@Environment(Store.self) private var store
|
2026-10-17 07:26:18 -07:00
|
|
|
|
@ViewState private var width: CGFloat = 0
|
|
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
|
ScrollView(.horizontal) {
|
|
|
|
|
|
StretchColumn(minWidth: width) {
|
|
|
|
|
|
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
2026-10-18 07:30:00 -07:00
|
|
|
|
if let taskId, let anchor = row.anchor, let drafts = store.drafts {
|
|
|
|
|
|
let comment = drafts.lineComment(taskId, anchor)
|
|
|
|
|
|
DiffLineRow(row: row, mark: comment != nil, hoverable: true)
|
|
|
|
|
|
.simultaneousGesture(TapGesture().onEnded {
|
|
|
|
|
|
drafts.toggleLineComment(taskId, anchor)
|
|
|
|
|
|
})
|
|
|
|
|
|
if comment != nil {
|
|
|
|
|
|
LineCommentBox(drafts: drafts, taskId: taskId, anchor: anchor)
|
|
|
|
|
|
.frame(width: max(0, width - 20))
|
|
|
|
|
|
.padding(.horizontal, 10)
|
|
|
|
|
|
.padding(.vertical, 4)
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
2026-10-18 07:30:00 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
DiffLineRow(row: row, mark: row.mark, hoverable: false)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-18 07:30:00 -07:00
|
|
|
|
|
|
|
|
|
|
private struct DiffLineRow: View {
|
|
|
|
|
|
let row: DiffRow
|
|
|
|
|
|
let mark: Bool
|
|
|
|
|
|
let hoverable: Bool
|
|
|
|
|
|
@ViewState private var hover = false
|
|
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
|
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)
|
|
|
|
|
|
.background(hover ? Palette.accent.opacity(0.08) : .clear)
|
|
|
|
|
|
.overlay(alignment: .leading) {
|
|
|
|
|
|
if mark {
|
|
|
|
|
|
Rectangle().fill(Palette.accent).frame(width: 3)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.contentShape(Rectangle())
|
|
|
|
|
|
.help(hoverable ? "Click to comment on this line" : "")
|
|
|
|
|
|
.onHover { on in
|
|
|
|
|
|
hover = hoverable && on
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private struct LineCommentBox: View {
|
|
|
|
|
|
let drafts: Drafts
|
|
|
|
|
|
let taskId: String
|
|
|
|
|
|
let anchor: LineAnchor
|
|
|
|
|
|
@FocusState private var focused: Bool
|
|
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
|
let text = Binding(get: { drafts.lineComment(taskId, anchor)?.text ?? "" }, set: { drafts.setLineComment(taskId, anchor, $0) })
|
|
|
|
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
|
|
|
|
TextEditor(text: text)
|
|
|
|
|
|
.font(Fonts.font(12))
|
|
|
|
|
|
.scrollContentBackground(.hidden)
|
|
|
|
|
|
.focused($focused)
|
|
|
|
|
|
.padding(.horizontal, 5)
|
|
|
|
|
|
.padding(.vertical, 4)
|
|
|
|
|
|
.frame(height: 64)
|
|
|
|
|
|
.background(Palette.surface, in: RoundedRectangle(cornerRadius: 6))
|
|
|
|
|
|
.overlay(alignment: .topLeading) {
|
|
|
|
|
|
if text.wrappedValue.isEmpty {
|
|
|
|
|
|
Text("Comment on line \(anchor.line)…")
|
|
|
|
|
|
.foregroundStyle(Palette.muted)
|
|
|
|
|
|
.padding(.horizontal, 10)
|
|
|
|
|
|
.padding(.vertical, 4)
|
|
|
|
|
|
.allowsHitTesting(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? Palette.accent : Palette.border))
|
|
|
|
|
|
Button("remove") {
|
|
|
|
|
|
drafts.dropLineComment(taskId, anchor)
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(SmallLinkButtonStyle())
|
|
|
|
|
|
}
|
|
|
|
|
|
.onAppear {
|
|
|
|
|
|
if text.wrappedValue.isEmpty {
|
|
|
|
|
|
focused = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|