Files
2026-10-19 09:53:54 -07:00

308 lines
11 KiB
Swift
Raw Permalink 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 ByreAPI
import SwiftUI
typealias FindingState = Components.Schemas.FindingState
typealias FindingResponse = Components.Schemas.FindingResponse
private let findingHead = Re(#"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@"#)
@MainActor
private enum SnapshotPatches {
static var changes: [String: Task<TaskChanges, any Error>] = [:]
static var patches: [String: Task<String?, any Error>] = [:]
static func patch(_ api: API, _ taskId: String, _ n: Int, _ path: String) async throws -> String? {
let key = "\(api.origin.absoluteString)/\(taskId)/\(n)/\(path)"
if let cached = patches[key] {
return try await cached.value
}
let fetched = Task<String?, any Error> {
let ck = "\(api.origin.absoluteString)/\(taskId)/\(n)"
let pending = changes[ck] ?? Task {
try await api.client.getTaskChanges(path: .init(taskId: taskId), query: .init(snapshot: n)).ok.body.json
}
changes[ck] = pending
let c: TaskChanges
do {
c = try await pending.value
} catch {
changes[ck] = nil
throw error
}
guard let f = c.files.first(where: { $0.path == path }), !f.binary else {
return nil
}
return try await 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
}
patches[key] = fetched
do {
return try await fetched.value
} catch {
patches[key] = nil
throw error
}
}
}
private func linesAround(_ patch: String, _ line: Int, _ end: Int) -> [(kind: PatchLine, text: String, at: Int)] {
var lines: [(kind: PatchLine, text: String, at: Int)] = []
var n = 0
var inHunk = false
for l in patch.components(separatedBy: "\n") {
if let m = findingHead.exec(l) {
n = Int(m[1]!) ?? 0
inHunk = true
continue
}
if !inHunk {
continue
}
if l.hasPrefix("+") {
lines.append((.add, l, n))
n += 1
} else if l.hasPrefix("-") {
lines.append((.del, l, n))
} else if l.hasPrefix(" ") {
lines.append((.ctx, l, n))
n += 1
}
}
return lines.filter { $0.at >= line - 3 && $0.at <= end + 3 }
}
func findingResponse(_ r: Review, _ index: Int) -> FindingResponse? {
r.findingResponses.additionalProperties[String(index)]
}
func findingOpen(_ r: Review, _ index: Int) -> Bool {
r.findingStates.additionalProperties[String(index)] == nil && findingResponse(r, index) == nil
}
func findingWhere(_ f: JSONDict) -> String {
let path = str(f, "path") ?? ""
let line = max(0, int(f, "line") ?? 0)
let endRaw = int(f, "end_line") ?? 0
let end = endRaw > line ? endRaw : line
return path.isEmpty ? "" : "\(path)\(line > 0 ? ":\(line)\(end > line ? "–\(end)" : "")" : "")"
}
struct FindingRow: View {
let review: Review
let index: Int
let taskId: String?
var body: some View {
let f = findings(review)[index]
let severity = jsString(f["severity"] ?? "")
let path = str(f, "path") ?? ""
let line = max(0, int(f, "line") ?? 0)
let endRaw = int(f, "end_line") ?? 0
let end = endRaw > line ? endRaw : line
let whereText = findingWhere(f)
let md = MDContext(taskId: taskId)
let response = findingResponse(review, index)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("\(index + 1).")
.font(Fonts.font(11, weight: 700))
.foregroundStyle(Palette.muted)
Text(severity)
.font(Fonts.font(11, weight: 700))
.foregroundStyle(severity == "critical" || severity == "high" ? Palette.danger : severity == "medium" ? Palette.warn : Palette.muted)
if let category = str(f, "category"), !category.isEmpty {
Text(category).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
Text(jsString(f["title"] ?? ""))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
if !whereText.isEmpty {
Text(whereText).font(Fonts.font(11)).foregroundStyle(Palette.muted).textSelection(.enabled)
}
Spacer(minLength: 0)
if let taskId {
FindingActions(taskId: taskId, review: review, index: index)
}
}
if let response {
Text("agent \(response.response.rawValue): \(response.reason)")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
Group {
if let taskId, !path.isEmpty, line > 0 {
FindingDiff(taskId: taskId, n: review.snapshotN, path: path, line: line, end: end)
}
MarkdownView(blocks: parseMarkdown(jsString(f["detail"] ?? "")), ctx: md)
ReviewImages(value: f["images"] ?? nil)
}
.opacity(response == nil ? 1 : 0.5)
}
.padding(.leading, 10)
.overlay(alignment: .leading) {
Rectangle().fill(Palette.border).frame(width: 1)
}
}
}
struct ReviewImages: View {
let value: (any Sendable)?
var body: some View {
let imgs = (value as? [(any Sendable)?] ?? []).map { $0 as? JSONDict ?? [:] }
if !imgs.isEmpty {
FlowLayout(spacing: 10) {
ForEach(Array(imgs.enumerated()), id: \.offset) { _, img in
let blob = jsString(img["blob"] ?? "")
let caption = str(img, "caption") ?? ""
VStack(alignment: .leading, spacing: 2) {
ByreImage(source: .blob(blob), src: "/api/blobs/\(blob)", maxHeight: 360)
if !caption.isEmpty {
Text(caption).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
}
}
.padding(.vertical, 8)
}
}
}
private struct FindingDiff: View {
let taskId: String
let n: Int
let path: String
let line: Int
let end: Int
@Environment(Store.self) private var store
@ViewState private var rows: [DiffRow]?
@ViewState private var failure: String?
var body: some View {
Group {
if let failure {
Text("diff not available: \(failure)")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.modifier(DiffBox())
} else if let rows, !rows.isEmpty {
DiffLines(rows: rows)
.modifier(DiffBox())
}
}
.task(id: "\(taskId)/\(n)/\(path)/\(line)/\(end)") {
failure = nil
do {
let patch = try await SnapshotPatches.patch(store.api, taskId, n, path)
let lines = patch.map { linesAround($0, line, end) } ?? []
var out: [DiffRow] = []
for (i, d) in lines.enumerated() {
if i > 0 {
let prev = lines[i - 1]
if d.kind != .del && prev.kind != .del && d.at > prev.at + 1 {
out.append(DiffRow(text: "⋯", kind: .hunk))
}
}
let mark = d.kind != .del && d.at >= line && d.at <= end
out.append(DiffRow(text: d.text, kind: d.kind, lineno: d.kind == .del ? "" : String(d.at), mark: mark))
}
rows = out
} catch {
failure = errorText(error)
}
}
}
}
private struct DiffBox: ViewModifier {
func body(content: Content) -> some View {
content
.background(Palette.bg)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
.padding(.top, 2)
}
}
struct FindingActions: View {
let taskId: String
let review: Review
let index: Int
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var busy = false
var body: some View {
let response = findingResponse(review, index)?.response
let state = review.findingStates.additionalProperties[String(index)]
Group {
if let state {
Text(state == .fixed ? "fix sent" : "ignored")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
} else if response != .fixing {
HStack(spacing: 6) {
Button("fix") {
fix()
}
.buttonStyle(SmallLinkButtonStyle())
.help(response == .ignoring ? "Overrule the agent: send this finding to it to fix" : "Send this finding to the task's agent to fix")
if response == nil {
Button("ignore") {
ignore()
}
.buttonStyle(SmallLinkButtonStyle())
.help("Mark this finding done without fixing it; Merge won't warn about it")
}
}
.disabled(busy)
}
}
.onChange(of: state) {
busy = false
}
}
private func mark(_ s: FindingState) async throws {
_ = try await store.api.client.setFindingState(path: .init(reviewId: review.id, index: index), body: .json(.init(state: s))).ok
}
private func fix() {
let f = findings(review)[index]
let whereText = findingWhere(f)
let text = "Fix finding \(index + 1) from the review of snapshot #\(review.snapshotN):\n\n**[\(jsString(f["severity"] ?? ""))] \(jsString(f["title"] ?? ""))**\(whereText.isEmpty ? "" : " (`\(whereText)`)")\n\n\(jsString(f["detail"] ?? ""))"
busy = true
Task {
do {
try await store.messageTask(taskId, text)
} catch {
busy = false
overlays.showAlert(errorText(error))
return
}
do {
try await mark(.fixed)
} catch {
busy = false
overlays.showAlert("fix sent, but not recorded: \(errorText(error))")
}
}
}
private func ignore() {
busy = true
Task {
do {
try await mark(.ignored)
} catch {
busy = false
overlays.showAlert(errorText(error))
}
}
}
}