Add a Files tab to the project page

This commit is contained in:
Ian Gulliver
2026-10-22 19:17:08 -07:00
parent 2aba745531
commit d8ce1cee41
12 changed files with 769 additions and 437 deletions
+77 -32
View File
@@ -5,19 +5,31 @@ typealias DirListing = Components.Schemas.DirListing
typealias DirEntry = Components.Schemas.DirEntry
typealias FileContent = Components.Schemas.FileContent
enum FileRoot: Hashable {
case task(String)
case project(String)
var name: String {
if case .project = self {
return "repo"
}
return "worktree"
}
}
struct FilePlace: Equatable {
var dir = ""
var file: String?
}
private struct FilesKey: Equatable {
let taskId: String?
let root: FileRoot?
let place: FilePlace
}
extension Store {
var filePlace: FilePlace {
selectedTaskId.flatMap { filePlaces[$0] } ?? FilePlace()
func filePlace(_ root: FileRoot?) -> FilePlace {
root.flatMap { filePlaces[$0] } ?? FilePlace()
}
func openFile(_ taskId: String, _ path: String) {
@@ -34,13 +46,22 @@ extension Store {
}
func showInFiles(_ taskId: String, _ path: String) {
if path.isEmpty || path.hasSuffix("/") {
filePlaces[taskId] = FilePlace(dir: path == "/" ? path : String(path.dropLast()))
} else {
filePlaces[taskId] = FilePlace(dir: parent(path), file: path)
}
filePlaces[.task(taskId)] = filePlace(at: path)
route("#/task/\(taskId)/files")
}
func showInProjectFiles(_ projectId: String, _ path: String, _ overlays: Overlays) {
filePlaces[.project(projectId)] = filePlace(at: path)
overlays.projectTab = .files
route("#/project/\(projectId)")
}
private func filePlace(at path: String) -> FilePlace {
if path.isEmpty || path.hasSuffix("/") {
return FilePlace(dir: path == "/" ? path : String(path.dropLast()))
}
return FilePlace(dir: parent(path), file: path)
}
}
private func join(_ dir: String, _ name: String) -> String {
@@ -78,6 +99,7 @@ private func shown(_ c: FileContent) -> Int64 {
@Observable
private final class FilesModel {
let store: Store
var root: FileRoot?
private(set) var listing: DirListing?
private(set) var content: FileContent?
private(set) var error = ""
@@ -91,14 +113,14 @@ private final class FilesModel {
}
var place: FilePlace {
store.filePlace
store.filePlace(root)
}
func go(_ p: FilePlace) {
guard let taskId = store.selectedTaskId else {
guard let root else {
return
}
store.filePlaces[taskId] = p
store.filePlaces[root] = p
}
func reset() {
@@ -114,22 +136,29 @@ private final class FilesModel {
}
func load() async {
guard let taskId = store.selectedTaskId else {
guard let root else {
return
}
target += 1
let started = target
let p = place
let client = store.api.client
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
let fresh = switch root {
case .task(let id): try await client.getTaskFile(path: .init(taskId: id), query: .init(path: file)).ok.body.json
case .project(let id): try await client.getProjectFile(path: .init(projectId: id), query: .init(path: file)).ok.body.json
}
if started == target {
content = fresh
listing = nil
loads += 1
}
} else {
let fresh = try await store.api.client.getTaskDir(path: .init(taskId: taskId), query: .init(path: p.dir)).ok.body.json
let fresh = switch root {
case .task(let id): try await client.getTaskDir(path: .init(taskId: id), query: .init(path: p.dir)).ok.body.json
case .project(let id): try await client.getProjectDir(path: .init(projectId: id), query: .init(path: p.dir)).ok.body.json
}
if started == target {
listing = fresh
content = nil
@@ -147,10 +176,7 @@ private final class FilesModel {
}
}
func transcript(_ fresh: [ByreEvent]) {
if !fresh.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) {
return
}
func changed() {
timer?.cancel()
timer = Task {
try? await Task.sleep(for: .milliseconds(1500))
@@ -176,11 +202,12 @@ private final class FilesModel {
if let l = listing {
return "\(l.entries.count)\(l.truncated ? "+" : "") \(l.entries.count == 1 ? "entry" : "entries")"
}
return store.selectedTaskId != nil ? "Loading…" : ""
return root != nil ? "Loading…" : ""
}
}
struct FilesView: View {
let root: FileRoot?
@Environment(Store.self) private var store
@ViewState private var model: FilesModel?
@@ -192,12 +219,26 @@ struct FilesView: View {
}
}
.frame(maxHeight: .infinity, alignment: .top)
.task(id: FilesKey(taskId: store.selectedTaskId, place: store.filePlace)) {
.task(id: FilesKey(root: root, place: store.filePlace(root))) {
let m = model ?? FilesModel(store)
model = m
m.root = root
m.reset()
let off = store.selectedFeed?.appended.add { [weak m] events in
m?.transcript(events)
let off: (() -> Void)? = switch root {
case .task:
store.selectedFeed?.appended.add { [weak m] events in
if events.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) {
m?.changed()
}
}
case .project(let id):
store.events.add { [weak m] ev in
if (ev.kind == "task.merged" || ev.kind == "task.merged_to_main") && ev.projectId == id {
m?.changed()
}
}
case nil:
nil
}
await untilCancelled()
off?()
@@ -217,7 +258,7 @@ private struct FilesHead: View {
let base = absolute ? "/" : ""
HStack(alignment: .firstTextBaseline, spacing: 12) {
FlowLayout(spacing: 2) {
crumb(absolute ? "/" : "worktree", base, parts.isEmpty)
crumb(absolute ? "/" : model.root?.name ?? "worktree", base, parts.isEmpty)
ForEach(Array(parts.enumerated()), id: \.offset) { i, part in
if !absolute || i > 0 {
Text("/").foregroundStyle(Palette.muted)
@@ -255,18 +296,18 @@ private struct FilesBody: View {
@Environment(Store.self) private var store
var body: some View {
if store.selectedTaskId == nil {
if model.root == nil {
EmptyText(text: "No task selected.")
} else if !model.error.isEmpty {
EmptyText(text: model.error, error: true)
} else if let c = model.content, let taskId = store.selectedTaskId {
} else if let c = model.content, let root = model.root {
if let file = model.place.file, isImage(file) {
FileImage(taskId: taskId, path: file, load: model.loads)
FileImage(root: root, path: file, load: model.loads)
.id(model.generation)
} else if c.binary {
EmptyText(text: "Binary file.")
} else if model.place.file?.lowercased().hasSuffix(".md") == true {
FileMarkdown(content: c, taskId: taskId, dir: model.place.dir)
FileMarkdown(content: c, root: root, dir: model.place.dir)
.id(model.generation)
} else {
FileText(content: c)
@@ -285,7 +326,7 @@ private struct FilesBody: View {
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 && isPage(e.name), let taskId = store.selectedTaskId {
} else if e._type == .file && isPage(e.name), case .task(let taskId) = model.root {
store.openPage(.file(taskId: taskId, path: join(l.path, e.name)))
} else if e._type == .file {
model.go(FilePlace(dir: l.path, file: join(l.path, e.name)))
@@ -355,13 +396,17 @@ private struct EntryRow: View {
private struct FileMarkdown: View {
let content: FileContent
let taskId: String?
let root: FileRoot
let dir: String
var body: some View {
let ctx = switch root {
case .task(let id): MDContext(taskId: id, dir: dir)
case .project(let id): MDContext(project: id, dir: dir)
}
ScrollView {
VStack(alignment: .leading, spacing: 0) {
MarkdownView(blocks: parseMarkdown(content.text), ctx: MDContext(taskId: taskId, dir: dir))
MarkdownView(blocks: parseMarkdown(content.text), ctx: ctx)
if content.truncated {
Text("Showing the first \(bytes(shown(content))) of \(bytes(content.size)).")
.foregroundStyle(Palette.muted)
@@ -376,7 +421,7 @@ private struct FileMarkdown: View {
}
private struct FileImage: View {
let taskId: String
let root: FileRoot
let path: String
let load: Int
@Environment(Store.self) private var store
@@ -407,7 +452,7 @@ private struct FileImage: View {
}
.task(id: load) {
do {
let data = try await ImageCache.fetch(.worktree(taskId: taskId, path: path), store.api)
let data = try await ImageCache.fetch(.file(root, path: path), store.api)
guard let img = PlatformImage(data: data) else {
image = nil
error = "Not a readable image."