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

352 lines
11 KiB
Swift

import ByreAPI
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 "\((Double(n) / 1024).formatted(.number.precision(.fractionLength(1)).grouping(.never))) KiB"
}
return "\((Double(n) / 1024 / 1024).formatted(.number.precision(.fractionLength(1)).grouping(.never))) MiB"
}
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(alignment: .top, spacing: 12) {
HStack(alignment: .top, spacing: 0) {
Text(label)
.foregroundStyle(kind == .other || ignored ? Palette.muted : kind == .dir ? Palette.accent : Palette.text)
if symlink {
Text(" →").foregroundStyle(Palette.muted)
}
}
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)
}
}
}