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