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
+5 -1
View File
@@ -38,6 +38,10 @@ enum LeftTab: String {
case lead, status, notes, workflows, server, settings
}
enum ProjectTab {
case tasks, files, settings
}
@MainActor
@Observable
final class Overlays {
@@ -53,7 +57,7 @@ final class Overlays {
var leadFocus = 0
var tabFocus = 0
var projectFocus = false
var projectSettings = false
var projectTab = ProjectTab.tasks
var taskList = false
@ObservationIgnored var pickerFrame: CGRect = .zero
@ObservationIgnored var menuHover = false
+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."
+10 -5
View File
@@ -3,7 +3,7 @@ import SwiftUI
enum ImageSource: Hashable {
case blob(String)
case worktree(taskId: String, path: String)
case file(FileRoot, path: String)
case data(Data)
}
@@ -11,7 +11,7 @@ private let dataImage = Re(#"^data:image/(png|jpeg|gif|webp);"#, caseless: true)
private let fileURL = Re(#"^file://"#, caseless: true)
private let anyScheme = Re(#"^[a-z][a-z0-9+.-]*:"#, caseless: true)
func worktreeImageSource(_ path: String, _ taskId: String?, dir: String = "") -> ImageSource? {
func worktreeImageSource(_ path: String, _ root: FileRoot?, dir: String = "") -> ImageSource? {
if dataImage.test(path) {
guard let comma = path.firstIndex(of: ","), path[..<comma].lowercased().hasSuffix(";base64"), let data = Data(base64Encoded: String(path[path.index(after: comma)...])) else {
return nil
@@ -29,10 +29,10 @@ func worktreeImageSource(_ path: String, _ taskId: String?, dir: String = "") ->
} else if !dir.isEmpty && !file.hasPrefix("/") {
file = "\(dir)/\(file)"
}
guard let taskId else {
guard let root else {
return nil
}
return .worktree(taskId: taskId, path: file)
return .file(root, path: file)
}
@MainActor
@@ -76,11 +76,16 @@ final class ImageCache {
case .png(let b), .jpeg(let b), .imageGif(let b), .imageWebp(let b): b
}
return try await Data(collecting: body, upTo: 32 << 20)
case .worktree(let taskId, let path):
case .file(.task(let taskId), let path):
let body = switch try await api.client.getTaskImage(path: .init(taskId: taskId), query: .init(path: path)).ok.body {
case .png(let b), .jpeg(let b), .imageGif(let b), .imageWebp(let b), .imageSvgXml(let b): b
}
return try await Data(collecting: body, upTo: 32 << 20)
case .file(.project(let projectId), let path):
let body = switch try await api.client.getProjectImage(path: .init(projectId: projectId), query: .init(path: path)).ok.body {
case .png(let b), .jpeg(let b), .imageGif(let b), .imageWebp(let b), .imageSvgXml(let b): b
}
return try await Data(collecting: body, upTo: 32 << 20)
}
}
}
+31 -13
View File
@@ -18,11 +18,14 @@ enum AppLink {
return c.url!
}
static func file(_ taskId: String, _ path: String) -> URL {
static func file(_ root: FileRoot, _ path: String) -> URL {
var c = URLComponents()
c.scheme = "byre-app"
c.host = "file"
c.queryItems = [URLQueryItem(name: "task", value: taskId), URLQueryItem(name: "path", value: path)]
c.queryItems = switch root {
case .task(let id): [URLQueryItem(name: "task", value: id), URLQueryItem(name: "path", value: path)]
case .project(let id): [URLQueryItem(name: "project", value: id), URLQueryItem(name: "path", value: path)]
}
return c.url!
}
@@ -61,7 +64,11 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct
case "route":
store.route(q["hash"] ?? "")
case "file":
store.openFile(q["task"] ?? "", q["path"] ?? "")
if let project = q["project"] {
store.showInProjectFiles(project, q["path"] ?? "", overlays)
} else {
store.openFile(q["task"] ?? "", q["path"] ?? "")
}
case "preview":
let taskId = q["task"] ?? ""
store.route("#/task/\(taskId)")
@@ -169,17 +176,23 @@ struct InlineRenderer {
let store: Store
let size: CGFloat
let taskId: String?
let project: String?
let weight: CGFloat
let dir: String
private let root: String
init(store: Store, size: CGFloat, taskId: String?, weight: CGFloat = 400, dir: String = "") {
init(store: Store, size: CGFloat, taskId: String?, project: String? = nil, weight: CGFloat = 400, dir: String = "") {
self.store = store
self.size = size
self.taskId = taskId
self.project = project
self.weight = weight
self.dir = dir
root = taskId.flatMap { store.findTask($0)?.worktreePath } ?? ""
if let taskId {
root = store.findTask(taskId)?.worktreePath ?? ""
} else {
root = project.flatMap { id in store.projects.first { $0.id == id }?.repoPath } ?? ""
}
}
private struct Style {
@@ -275,7 +288,7 @@ struct InlineRenderer {
s.italic = true
render(inner, s, &out)
case .image(let src, let alt):
if let source = worktreeImageSource(src, taskId, dir: dir) {
if let source = worktreeImageSource(src, files, dir: dir) {
out.append(.image(source, src: src))
} else {
link(src, [.text(alt.isEmpty ? src : alt)], style, &out)
@@ -327,11 +340,15 @@ struct InlineRenderer {
render(inner, style, &out)
}
private var files: FileRoot? {
taskId.map(FileRoot.task) ?? project.map(FileRoot.project)
}
private func fileLink(_ path: String) -> URL? {
guard let taskId, !root.isEmpty, let p = normalPath(path) else {
guard let files, !root.isEmpty, let p = normalPath(path) else {
return nil
}
return AppLink.file(taskId, p)
return AppLink.file(files, p)
}
private func codeFile(_ code: String) -> URL? {
@@ -451,6 +468,7 @@ struct MDContext {
var size: CGFloat = 13
var color: Color = Palette.text
var taskId: String?
var project: String?
var choice: ((MDItem, Bool) -> AnyView)?
var discuss: ((MDItem) -> AnyView)?
var gridColumns = 4
@@ -590,7 +608,7 @@ private struct FlowBuilder {
}
private func inline(_ inlines: [MDInline], _ size: CGFloat) -> NSAttributedString {
let a = InlineRenderer(store: store, size: size, taskId: ctx.taskId, weight: ctx.weight, dir: ctx.dir).text(inlines)
let a = InlineRenderer(store: store, size: size, taskId: ctx.taskId, project: ctx.project, weight: ctx.weight, dir: ctx.dir).text(inlines)
return nativeString(a)
}
@@ -902,7 +920,7 @@ struct InlineView: View {
@Environment(\.centerImages) private var centerImages
var body: some View {
let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId, weight: ctx.weight, dir: ctx.dir).segments(inlines)
let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId, project: ctx.project, weight: ctx.weight, dir: ctx.dir).segments(inlines)
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(segs.enumerated()), id: \.offset) { _, seg in
switch seg {
@@ -948,7 +966,7 @@ private struct BlockView: View {
case .paragraph(let inlines):
InlineView(inlines: inlines, ctx: ctx)
case .heading(let level, let inlines):
InlineView(inlines: [.strong(inlines)], ctx: MDContext(size: level == 3 ? 14 : 13, color: ctx.color, taskId: ctx.taskId, dir: ctx.dir))
InlineView(inlines: [.strong(inlines)], ctx: MDContext(size: level == 3 ? 14 : 13, color: ctx.color, taskId: ctx.taskId, project: ctx.project, dir: ctx.dir))
.padding(.top, 4)
case .rule:
Rectangle()
@@ -956,7 +974,7 @@ private struct BlockView: View {
.frame(height: 1)
.padding(.vertical, 2)
case .quote(let blocks):
MarkdownView(blocks: blocks, ctx: MDContext(size: ctx.size, color: Palette.muted, taskId: ctx.taskId, dir: ctx.dir))
MarkdownView(blocks: blocks, ctx: MDContext(size: ctx.size, color: Palette.muted, taskId: ctx.taskId, project: ctx.project, dir: ctx.dir))
.padding(.leading, 13)
.overlay(alignment: .leading) {
Rectangle().fill(Palette.border).frame(width: 3)
@@ -1107,7 +1125,7 @@ private struct DetailsBlock: View {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(open ? "▾" : "▸")
.foregroundStyle(Palette.accent)
InlineView(inlines: summary, ctx: MDContext(size: ctx.size, color: Palette.accent, taskId: ctx.taskId, dir: ctx.dir))
InlineView(inlines: summary, ctx: MDContext(size: ctx.size, color: Palette.accent, taskId: ctx.taskId, project: ctx.project, dir: ctx.dir))
}
.contentShape(Rectangle())
}
+1 -1
View File
@@ -244,7 +244,7 @@ struct TaskTabBody: View {
case .changes:
ChangesView()
case .files:
FilesView()
FilesView(root: store.selectedTaskId.map(FileRoot.task))
case .page:
if let shown = store.selectedTaskId.flatMap({ store.shownPages[$0] }) {
PageTabs(shown: shown)
+19 -12
View File
@@ -17,11 +17,14 @@ struct ProjectView: View {
VStack(spacing: 0) {
if store.focusedProject() != nil {
HStack(spacing: 4) {
PaneTabButton(label: "Tasks", active: !overlays.projectSettings) {
overlays.projectSettings = false
PaneTabButton(label: "Tasks", active: overlays.projectTab == .tasks) {
overlays.projectTab = .tasks
}
PaneTabButton(label: "Settings", active: overlays.projectSettings) {
overlays.projectSettings = true
PaneTabButton(label: "Files", active: overlays.projectTab == .files) {
overlays.projectTab = .files
}
PaneTabButton(label: "Settings", active: overlays.projectTab == .settings) {
overlays.projectTab = .settings
}
Spacer(minLength: 0)
}
@@ -30,14 +33,18 @@ struct ProjectView: View {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
ScrollView {
if let p = store.focusedProject() {
ProjectBlock(project: p, todo: todos[p.id], settings: overlays.projectSettings)
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
} else {
Muted(text: "No project selected.")
if let p = store.focusedProject(), overlays.projectTab == .files {
FilesView(root: .project(p.id))
} else {
ScrollView {
if let p = store.focusedProject() {
ProjectBlock(project: p, todo: todos[p.id], settings: overlays.projectTab == .settings)
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
} else {
Muted(text: "No project selected.")
}
}
}
}
+1 -1
View File
@@ -525,7 +525,7 @@ private struct Attachments: View {
case .image:
let src = (a.image?.isEmpty == false ? a.image : a.path) ?? ""
let caption = a.caption ?? ""
if let source = worktreeImageSource(src, taskId) {
if let source = worktreeImageSource(src, .task(taskId)) {
ByreImage(source: source, src: src)
} else if !src.isEmpty {
InlineView(inlines: [.link(href: src, [.text(caption.isEmpty ? src : caption)])], ctx: MDContext(taskId: taskId))
+1 -1
View File
@@ -83,7 +83,7 @@ final class Store {
private(set) var leadMore = false
var todoBusy: [String: String] = [:]
var pickedMembers: [String: String] = [:]
var filePlaces: [String: FilePlace] = [:]
var filePlaces: [FileRoot: FilePlace] = [:]
private(set) var drafts: Drafts?
let images: ImageDrafts
let imageCache = ImageCache()