352 lines
11 KiB
Swift
352 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
typealias DirListing = Components.Schemas.DirListing
|
|
typealias DirEntry = Components.Schemas.DirEntry
|
|
typealias FileContent = Components.Schemas.FileContent
|
|
|
|
private struct Place: Equatable {
|
|
var dir = ""
|
|
var file: String?
|
|
}
|
|
|
|
@MainActor
|
|
private var places: [String: Place] = [:]
|
|
|
|
private func join(_ dir: String, _ name: String) -> String {
|
|
dir.isEmpty ? name : "\(dir)/\(name)"
|
|
}
|
|
|
|
private func parent(_ path: String) -> String {
|
|
path.lastIndex(of: "/").map { String(path[..<$0]) } ?? ""
|
|
}
|
|
|
|
private func bytes(_ n: Int64) -> String {
|
|
if n < 1024 {
|
|
return "\(n) B"
|
|
}
|
|
if n < 1024 * 1024 {
|
|
return String(format: "%.1f KiB", Double(n) / 1024)
|
|
}
|
|
return String(format: "%.1f MiB", Double(n) / 1024 / 1024)
|
|
}
|
|
|
|
private func shown(_ c: FileContent) -> Int64 {
|
|
Int64(c.text.utf8.count)
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
private final class FilesModel {
|
|
let store: Store
|
|
private(set) var listing: DirListing?
|
|
private(set) var content: FileContent?
|
|
private(set) var error = ""
|
|
private(set) var generation = 0
|
|
@ObservationIgnored private var target = 0
|
|
@ObservationIgnored private var timer: Task<Void, Never>?
|
|
|
|
init(_ store: Store) {
|
|
self.store = store
|
|
}
|
|
|
|
var place: Place {
|
|
store.selectedTaskId.flatMap { places[$0] } ?? Place()
|
|
}
|
|
|
|
func go(_ p: Place) {
|
|
guard let taskId = store.selectedTaskId else {
|
|
return
|
|
}
|
|
places[taskId] = p
|
|
listing = nil
|
|
content = nil
|
|
error = ""
|
|
generation += 1
|
|
Task {
|
|
await load()
|
|
}
|
|
}
|
|
|
|
func reset() {
|
|
target += 1
|
|
timer?.cancel()
|
|
listing = nil
|
|
content = nil
|
|
error = ""
|
|
Task {
|
|
await load()
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
target += 1
|
|
timer?.cancel()
|
|
}
|
|
|
|
func load() async {
|
|
guard let taskId = store.selectedTaskId else {
|
|
return
|
|
}
|
|
target += 1
|
|
let started = target
|
|
let p = place
|
|
do {
|
|
if let file = p.file {
|
|
let fresh = try await store.api.client.getTaskFile(path: .init(taskId: taskId), query: .init(path: file)).ok.body.json
|
|
if started == target {
|
|
content = fresh
|
|
listing = nil
|
|
}
|
|
} else {
|
|
let fresh = try await store.api.client.getTaskDir(path: .init(taskId: taskId), query: .init(path: p.dir)).ok.body.json
|
|
if started == target {
|
|
listing = fresh
|
|
content = nil
|
|
}
|
|
}
|
|
if started == target {
|
|
error = ""
|
|
}
|
|
} catch {
|
|
if started == target {
|
|
self.error = errorText(error)
|
|
listing = nil
|
|
content = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func transcript(_ fresh: [ByreEvent]) {
|
|
if !fresh.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) {
|
|
return
|
|
}
|
|
timer?.cancel()
|
|
timer = Task {
|
|
try? await Task.sleep(for: .milliseconds(1500))
|
|
if !Task.isCancelled {
|
|
await load()
|
|
}
|
|
}
|
|
}
|
|
|
|
var summary: String {
|
|
if !error.isEmpty {
|
|
return ""
|
|
}
|
|
if let c = content {
|
|
if c.binary {
|
|
return "\(bytes(c.size)) · binary"
|
|
}
|
|
return c.truncated ? "\(bytes(c.size)) · showing the first \(bytes(shown(c)))" : bytes(c.size)
|
|
}
|
|
if let l = listing {
|
|
return "\(l.entries.count)\(l.truncated ? "+" : "") \(l.entries.count == 1 ? "entry" : "entries")"
|
|
}
|
|
return store.selectedTaskId != nil ? "Loading…" : ""
|
|
}
|
|
}
|
|
|
|
struct FilesView: View {
|
|
@Environment(Store.self) private var store
|
|
@ViewState private var model: FilesModel?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
if let model {
|
|
FilesHead(model: model)
|
|
FilesBody(model: model)
|
|
}
|
|
}
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.task(id: store.selectedTaskId) {
|
|
let m = model ?? FilesModel(store)
|
|
model = m
|
|
m.reset()
|
|
let off = store.selectedFeed?.appended.add { [weak m] events in
|
|
m?.transcript(events)
|
|
}
|
|
await untilCancelled()
|
|
off?()
|
|
m.stop()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct FilesHead: View {
|
|
let model: FilesModel
|
|
|
|
var body: some View {
|
|
let p = model.place
|
|
let path = p.file ?? p.dir
|
|
let parts = path.isEmpty ? [] : path.components(separatedBy: "/")
|
|
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
|
FlowLayout(spacing: 2) {
|
|
crumb("worktree", "", parts.isEmpty)
|
|
ForEach(Array(parts.enumerated()), id: \.offset) { i, part in
|
|
Text("/").foregroundStyle(Palette.muted)
|
|
crumb(part, parts[...i].joined(separator: "/"), i == parts.count - 1)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
Text(model.summary)
|
|
.font(Fonts.font(11))
|
|
.foregroundStyle(Palette.muted)
|
|
.lineLimit(1)
|
|
.fixedSize()
|
|
}
|
|
.modifier(HeaderBar())
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func crumb(_ label: String, _ dir: String, _ last: Bool) -> some View {
|
|
if last {
|
|
Text(label).foregroundStyle(Palette.text)
|
|
} else {
|
|
Button(label) {
|
|
model.go(Place(dir: dir))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(Palette.accent)
|
|
.padding(.horizontal, 2)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct FilesBody: View {
|
|
let model: FilesModel
|
|
@Environment(Store.self) private var store
|
|
|
|
var body: some View {
|
|
if store.selectedTaskId == nil {
|
|
EmptyText(text: "No task selected.")
|
|
} else if !model.error.isEmpty {
|
|
EmptyText(text: model.error, error: true)
|
|
} else if let c = model.content {
|
|
if c.binary {
|
|
EmptyText(text: "Binary file.")
|
|
} else {
|
|
FileText(content: c)
|
|
.id(model.generation)
|
|
}
|
|
} else if let l = model.listing {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
if !l.path.isEmpty {
|
|
EntryRow(label: "../", kind: .dir, ignored: false, meta: "", symlink: false) {
|
|
model.go(Place(dir: parent(l.path)))
|
|
}
|
|
}
|
|
ForEach(l.entries, id: \.name) { e in
|
|
let meta = [e.ignored ? "ignored" : nil, e._type == .file ? bytes(e.size) : nil].compactMap { $0 }.joined(separator: " · ")
|
|
EntryRow(label: e._type == .dir ? "\(e.name)/" : e.name, kind: e._type, ignored: e.ignored, meta: meta, symlink: e.symlink) {
|
|
if e._type == .dir {
|
|
model.go(Place(dir: join(l.path, e.name)))
|
|
} else if e._type == .file {
|
|
model.go(Place(dir: l.path, file: join(l.path, e.name)))
|
|
}
|
|
}
|
|
}
|
|
if l.entries.isEmpty {
|
|
Muted(text: "Empty directory.")
|
|
}
|
|
if l.truncated {
|
|
Text("Only the first \(l.entries.count) entries are shown.")
|
|
.foregroundStyle(Palette.muted)
|
|
.padding(.horizontal, 8)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.top, 6)
|
|
.padding(.bottom, 16)
|
|
}
|
|
.id(model.generation)
|
|
} else {
|
|
EmptyText(text: "Loading…")
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct EntryRow: View {
|
|
let label: String
|
|
let kind: DirEntry._TypePayload
|
|
let ignored: Bool
|
|
let meta: String
|
|
let symlink: Bool
|
|
let action: () -> Void
|
|
@ViewState private var hover = false
|
|
|
|
var body: some View {
|
|
let row = HStack(spacing: 12) {
|
|
HStack(spacing: 0) {
|
|
Text(label)
|
|
.foregroundStyle(kind == .other || ignored ? Palette.muted : kind == .dir ? Palette.accent : Palette.text)
|
|
if symlink {
|
|
Text(" →").foregroundStyle(Palette.muted)
|
|
}
|
|
}
|
|
.lineLimit(1)
|
|
Spacer(minLength: 0)
|
|
Text(meta)
|
|
.font(Fonts.font(11))
|
|
.foregroundStyle(Palette.muted)
|
|
.lineLimit(1)
|
|
.fixedSize()
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 2)
|
|
.background(hover && kind != .other ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 4))
|
|
.contentShape(Rectangle())
|
|
if kind == .other {
|
|
row.help("Leaves the worktree, points nowhere, or isn't a file or directory")
|
|
} else {
|
|
Button(action: action) {
|
|
row
|
|
}
|
|
.buttonStyle(.plain)
|
|
.onHover { hover = $0 }
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct FileText: View {
|
|
let content: FileContent
|
|
|
|
var body: some View {
|
|
var lines = content.text.components(separatedBy: "\n")
|
|
if lines.last == "" {
|
|
lines.removeLast()
|
|
}
|
|
let numbers = (1...max(1, lines.count)).map(String.init).joined(separator: "\n")
|
|
return ScrollView([.vertical, .horizontal]) {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(alignment: .top, spacing: 0) {
|
|
Text(verbatim: lines.isEmpty ? "" : numbers)
|
|
.foregroundStyle(Palette.muted)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(minWidth: 42, alignment: .trailing)
|
|
.padding(.trailing, 6)
|
|
.overlay(alignment: .trailing) {
|
|
Rectangle().fill(Palette.border).frame(width: 1)
|
|
}
|
|
.padding(.trailing, 12)
|
|
Text(verbatim: lines.joined(separator: "\n"))
|
|
.foregroundStyle(Palette.text)
|
|
.textSelection(.enabled)
|
|
.padding(.trailing, 10)
|
|
}
|
|
.font(Fonts.font(11))
|
|
.fixedSize()
|
|
.padding(.vertical, 6)
|
|
if content.truncated {
|
|
Text("Showing the first \(bytes(shown(content))) of \(bytes(content.size)).")
|
|
.foregroundStyle(Palette.muted)
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 6)
|
|
.padding(.bottom, 16)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|