Add a Files tab to the project page
This commit is contained in:
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user