import ByreAPI import SwiftUI typealias DirListing = Components.Schemas.DirListing typealias DirEntry = Components.Schemas.DirEntry typealias FileContent = Components.Schemas.FileContent struct FilePlace: Equatable { var dir = "" var file: String? } private struct FilesKey: Equatable { let taskId: String? let place: FilePlace } extension Store { var filePlace: FilePlace { selectedTaskId.flatMap { filePlaces[$0] } ?? FilePlace() } func openFile(_ taskId: String, _ path: String) { filePlaces[taskId] = path.hasSuffix("/") ? FilePlace(dir: String(path.dropLast())) : FilePlace(dir: parent(path), file: path) route("#/task/\(taskId)/files") } } 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? init(_ store: Store) { self.store = store } var place: FilePlace { store.filePlace } func go(_ p: FilePlace) { guard let taskId = store.selectedTaskId else { return } store.filePlaces[taskId] = p } func reset() { target += 1 timer?.cancel() listing = nil content = nil error = "" generation += 1 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: FilesKey(taskId: store.selectedTaskId, place: store.filePlace)) { 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(FilePlace(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 if model.place.file?.lowercased().hasSuffix(".md") == true { FileMarkdown(content: c, taskId: store.selectedTaskId, dir: model.place.dir) .id(model.generation) } 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(FilePlace(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(FilePlace(dir: join(l.path, e.name))) } else if e._type == .file { model.go(FilePlace(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 FileMarkdown: View { let content: FileContent let taskId: String? let dir: String var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { MarkdownView(blocks: parseMarkdown(content.text), ctx: MDContext(taskId: taskId, dir: dir)) if content.truncated { Text("Showing the first \(bytes(shown(content))) of \(bytes(content.size)).") .foregroundStyle(Palette.muted) .padding(.top, 12) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) .padding(.vertical, 10) } } } 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) } } }