Link worktree file paths in Markdown to the Files tab
This commit is contained in:
+3
-1
@@ -391,7 +391,9 @@ The Changes view lists the files with their counts, opens a file's diff on click
|
|||||||
|
|
||||||
The Files tab browses the selected task's worktree, read-only (`internal/server/files.go`). `GET /api/tasks/{taskId}/dir?path=` lists one directory, directories first and then by name, without entries named `.git`; `GET /api/tasks/{taskId}/file?path=` returns one regular file. Both take a slash-separated path relative to the worktree root and work only while the task is active (409 otherwise). Every path is opened through an `os.Root` on the worktree, so a path that leaves it, through `..`, an absolute path or a symlink at any depth, is a 400; a path with a `.git` component is also refused. A listing reports each entry as `dir`, `file` (with its size) or `other`, following symlinks that stay inside the worktree (`symlink` marks them); a symlink that leaves the worktree or points nowhere is `other` and can't be opened. Gitignored entries are listed with `ignored` set: one `git check-ignore --stdin` per listing (`git.Ignored`) over the entries' paths, taken from the directory's real path so a listing reached through an in-worktree symlink is checked where it really lives; untracked paths under an ignored directory count as ignored, tracked files never do. The view shows them dimmed with `ignored` beside them, and they open like anything else. A listing stops at `maxDirEntries` entries with `truncated` set. A file read sends at most `maxFileBytes`, cut back to a whole UTF-8 character, with `truncated` set when the file is longer; a file with a NUL byte or invalid UTF-8 in that part is `binary` and comes back with no text.
|
The Files tab browses the selected task's worktree, read-only (`internal/server/files.go`). `GET /api/tasks/{taskId}/dir?path=` lists one directory, directories first and then by name, without entries named `.git`; `GET /api/tasks/{taskId}/file?path=` returns one regular file. Both take a slash-separated path relative to the worktree root and work only while the task is active (409 otherwise). Every path is opened through an `os.Root` on the worktree, so a path that leaves it, through `..`, an absolute path or a symlink at any depth, is a 400; a path with a `.git` component is also refused. A listing reports each entry as `dir`, `file` (with its size) or `other`, following symlinks that stay inside the worktree (`symlink` marks them); a symlink that leaves the worktree or points nowhere is `other` and can't be opened. Gitignored entries are listed with `ignored` set: one `git check-ignore --stdin` per listing (`git.Ignored`) over the entries' paths, taken from the directory's real path so a listing reached through an in-worktree symlink is checked where it really lives; untracked paths under an ignored directory count as ignored, tracked files never do. The view shows them dimmed with `ignored` beside them, and they open like anything else. A listing stops at `maxDirEntries` entries with `truncated` set. A file read sends at most `maxFileBytes`, cut back to a whole UTF-8 character, with `truncated` set when the file is longer; a file with a NUL byte or invalid UTF-8 in that part is `binary` and comes back with no text.
|
||||||
|
|
||||||
The view shows a breadcrumb from `worktree` down to the current directory or file, the directory's entries (a `../` row to go up), or the file's text with line numbers and a note when only its start is shown. A file whose name ends in `.md` is rendered as Markdown with the transcript's renderer (`MarkdownView`) instead, with relative image paths taken from the file's own directory. Where you are is kept per task in memory, so switching tasks or tabs returns to it, but a relaunch starts at the root. It refetches what it shows 1.5s after the selected task's last tool result or turn end, keeping the scroll position.
|
The view shows a breadcrumb from `worktree` down to the current directory or file, the directory's entries (a `../` row to go up), or the file's text with line numbers and a note when only its start is shown. A file whose name ends in `.md` is rendered as Markdown with the transcript's renderer (`MarkdownView`) instead, with relative image paths taken from the file's own directory. Where you are is kept per task in memory (`Store.filePlaces`), so switching tasks or tabs returns to it, but a relaunch starts at the root. It refetches what it shows 1.5s after the selected task's last tool result or turn end, keeping the scroll position.
|
||||||
|
|
||||||
|
Markdown rendered with a task (its transcript, questions, reviews, Markdown files in Files) links the worktree paths it mentions to Files (`InlineRenderer` in `mac/Sources/Byre/MarkdownView.swift`): a relative Markdown link (`[x](docs/a.md)`, from the file's own directory inside Files), a code span holding a path with a `/` that ends in a file name with an extension or in `/` (`` `mac/Sources/` ``), and the same kind of path in plain text, file names only. An absolute path counts when it starts with the task's worktree. A `:line`, `:line:col` or `#L12` suffix is dropped. The link is `byre-app://file?task=&path=`, which sets the task's place in Files to that file (or directory, for a trailing `/`) and focuses the task's Files tab. Nothing checks that the path exists; one that doesn't opens Files on its 404.
|
||||||
|
|
||||||
## Terminals
|
## Terminals
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,26 @@ typealias DirListing = Components.Schemas.DirListing
|
|||||||
typealias DirEntry = Components.Schemas.DirEntry
|
typealias DirEntry = Components.Schemas.DirEntry
|
||||||
typealias FileContent = Components.Schemas.FileContent
|
typealias FileContent = Components.Schemas.FileContent
|
||||||
|
|
||||||
private struct Place: Equatable {
|
struct FilePlace: Equatable {
|
||||||
var dir = ""
|
var dir = ""
|
||||||
var file: String?
|
var file: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
private struct FilesKey: Equatable {
|
||||||
private var places: [String: Place] = [:]
|
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 {
|
private func join(_ dir: String, _ name: String) -> String {
|
||||||
dir.isEmpty ? name : "\(dir)/\(name)"
|
dir.isEmpty ? name : "\(dir)/\(name)"
|
||||||
@@ -50,22 +63,15 @@ private final class FilesModel {
|
|||||||
self.store = store
|
self.store = store
|
||||||
}
|
}
|
||||||
|
|
||||||
var place: Place {
|
var place: FilePlace {
|
||||||
store.selectedTaskId.flatMap { places[$0] } ?? Place()
|
store.filePlace
|
||||||
}
|
}
|
||||||
|
|
||||||
func go(_ p: Place) {
|
func go(_ p: FilePlace) {
|
||||||
guard let taskId = store.selectedTaskId else {
|
guard let taskId = store.selectedTaskId else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
places[taskId] = p
|
store.filePlaces[taskId] = p
|
||||||
listing = nil
|
|
||||||
content = nil
|
|
||||||
error = ""
|
|
||||||
generation += 1
|
|
||||||
Task {
|
|
||||||
await load()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
@@ -74,6 +80,7 @@ private final class FilesModel {
|
|||||||
listing = nil
|
listing = nil
|
||||||
content = nil
|
content = nil
|
||||||
error = ""
|
error = ""
|
||||||
|
generation += 1
|
||||||
Task {
|
Task {
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
@@ -159,7 +166,7 @@ struct FilesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity, alignment: .top)
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
.task(id: store.selectedTaskId) {
|
.task(id: FilesKey(taskId: store.selectedTaskId, place: store.filePlace)) {
|
||||||
let m = model ?? FilesModel(store)
|
let m = model ?? FilesModel(store)
|
||||||
model = m
|
model = m
|
||||||
m.reset()
|
m.reset()
|
||||||
@@ -204,7 +211,7 @@ private struct FilesHead: View {
|
|||||||
Text(label).foregroundStyle(Palette.text)
|
Text(label).foregroundStyle(Palette.text)
|
||||||
} else {
|
} else {
|
||||||
Button(label) {
|
Button(label) {
|
||||||
model.go(Place(dir: dir))
|
model.go(FilePlace(dir: dir))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(Palette.accent)
|
.foregroundStyle(Palette.accent)
|
||||||
@@ -237,16 +244,16 @@ private struct FilesBody: View {
|
|||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
if !l.path.isEmpty {
|
if !l.path.isEmpty {
|
||||||
EntryRow(label: "../", kind: .dir, ignored: false, meta: "", symlink: false) {
|
EntryRow(label: "../", kind: .dir, ignored: false, meta: "", symlink: false) {
|
||||||
model.go(Place(dir: parent(l.path)))
|
model.go(FilePlace(dir: parent(l.path)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ForEach(l.entries, id: \.name) { e in
|
ForEach(l.entries, id: \.name) { e in
|
||||||
let meta = [e.ignored ? "ignored" : nil, e._type == .file ? bytes(e.size) : nil].compactMap { $0 }.joined(separator: " · ")
|
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) {
|
EntryRow(label: e._type == .dir ? "\(e.name)/" : e.name, kind: e._type, ignored: e.ignored, meta: meta, symlink: e.symlink) {
|
||||||
if e._type == .dir {
|
if e._type == .dir {
|
||||||
model.go(Place(dir: join(l.path, e.name)))
|
model.go(FilePlace(dir: join(l.path, e.name)))
|
||||||
} else if e._type == .file {
|
} else if e._type == .file {
|
||||||
model.go(Place(dir: l.path, file: join(l.path, e.name)))
|
model.go(FilePlace(dir: l.path, file: join(l.path, e.name)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ private let quote = Re(#"^\s*>\s?(.*)$"#)
|
|||||||
private let rule = Re(#"^\s*([-*_])(\s*\1){2,}\s*$"#)
|
private let rule = Re(#"^\s*([-*_])(\s*\1){2,}\s*$"#)
|
||||||
private let imageOnly = Re(#"^\s*!\[[^\]]*\]\((<[^>]*>|[^)]*)\)\s*$"#)
|
private let imageOnly = Re(#"^\s*!\[[^\]]*\]\((<[^>]*>|[^)]*)\)\s*$"#)
|
||||||
private let tableSep = Re(#"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$"#)
|
private let tableSep = Re(#"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$"#)
|
||||||
private let inlineToken = Re(#"`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![A-Za-z0-9_])_([^_\s][^_]*?)_(?![A-Za-z0-9_])|!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)|\[([^\]]+)\]\(([^)\s]+)\)|(https?://[^\s<>()]+[^\s<>().,;:!?'"])"#)
|
private let inlineToken = Re(#"`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![A-Za-z0-9_])_([^_\s][^_]*?)_(?![A-Za-z0-9_])|!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)|\[([^\]]+)\]\((?:<([^>]+)>|([^)\s]+))\)|(https?://[^\s<>()]+[^\s<>().,;:!?'"])"#)
|
||||||
|
|
||||||
func parseMarkdown(_ src: String, choices: Bool = false) -> [MDBlock] {
|
func parseMarkdown(_ src: String, choices: Bool = false) -> [MDBlock] {
|
||||||
let text = src.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
|
let text = src.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
|
||||||
@@ -359,8 +359,8 @@ private func spans(_ src: String) -> [MDInline] {
|
|||||||
} else if let alt = g[6] {
|
} else if let alt = g[6] {
|
||||||
out.append(.image(src: trimmed(g[7] ?? g[8]!), alt: alt))
|
out.append(.image(src: trimmed(g[7] ?? g[8]!), alt: alt))
|
||||||
} else if let text = g[9] {
|
} else if let text = g[9] {
|
||||||
out.append(.link(href: g[10]!, spans(text)))
|
out.append(.link(href: g[10] ?? g[11]!, spans(text)))
|
||||||
} else if let bare = g[11] {
|
} else if let bare = g[12] {
|
||||||
out.append(.link(href: bare, [.text(bare)]))
|
out.append(.link(href: bare, [.text(bare)]))
|
||||||
}
|
}
|
||||||
rest = String(rest[range.upperBound...])
|
rest = String(rest[range.upperBound...])
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ enum AppLink {
|
|||||||
return c.url!
|
return c.url!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func file(_ taskId: String, _ 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)]
|
||||||
|
return c.url!
|
||||||
|
}
|
||||||
|
|
||||||
static func todo(_ projectId: String, _ title: String, discuss: Bool) -> URL {
|
static func todo(_ projectId: String, _ title: String, discuss: Bool) -> URL {
|
||||||
var c = URLComponents()
|
var c = URLComponents()
|
||||||
c.scheme = "byre-app"
|
c.scheme = "byre-app"
|
||||||
@@ -44,6 +52,8 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct
|
|||||||
switch c.host {
|
switch c.host {
|
||||||
case "route":
|
case "route":
|
||||||
store.route(q["hash"] ?? "")
|
store.route(q["hash"] ?? "")
|
||||||
|
case "file":
|
||||||
|
store.openFile(q["task"] ?? "", q["path"] ?? "")
|
||||||
case "todo":
|
case "todo":
|
||||||
store.startTodo(q["project"] ?? "", q["title"] ?? "", discuss: q["discuss"] == "1", overlays)
|
store.startTodo(q["project"] ?? "", q["title"] ?? "", discuss: q["discuss"] == "1", overlays)
|
||||||
case "suggest":
|
case "suggest":
|
||||||
@@ -110,14 +120,50 @@ private let httpLink = Re(#"^https?://"#, caseless: true)
|
|||||||
private let taskHref = Re(#"^#/task/(\w+)(/\w+)?$"#)
|
private let taskHref = Re(#"^#/task/(\w+)(/\w+)?$"#)
|
||||||
private let todoHref = Re(#"^#/todo/(\w+)/(.+)$"#)
|
private let todoHref = Re(#"^#/todo/(\w+)/(.+)$"#)
|
||||||
private let suggestHref = Re(#"^#/suggest/(\w+)/([^/]+)/(.+)$"#)
|
private let suggestHref = Re(#"^#/suggest/(\w+)/([^/]+)/(.+)$"#)
|
||||||
|
private let anyScheme = Re(#"^[a-z][a-z0-9+.-]*:"#, caseless: true)
|
||||||
|
private let pathLine = #"(?::\d+(?:[-:]\d+)?|#L\d+(?:-L?\d+)?)?"#
|
||||||
|
private let pathFile = #"[\w+@-][\w.+@-]*\.[A-Za-z]\w*"#
|
||||||
|
private let codePath = Re(#"^(?:\./)?((?:[\w.+@-]+/)+(?:"# + pathFile + ")?)" + pathLine + "$")
|
||||||
|
private let barePath = Re(#"(?<![\w./~@+-])(?:\./)?((?:[\w.+@-]+/)+"# + pathFile + ")" + pathLine)
|
||||||
|
private let rootedPath = Re(#"^((?:[\w.+@-]+/)*"# + pathFile + ")" + pathLine)
|
||||||
|
|
||||||
|
private func normalPath(_ path: String) -> String? {
|
||||||
|
var out: [String] = []
|
||||||
|
for part in path.split(separator: "/", omittingEmptySubsequences: true) {
|
||||||
|
if part == "." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if part == ".." {
|
||||||
|
if out.popLast() == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.append(String(part))
|
||||||
|
}
|
||||||
|
if out.isEmpty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out.joined(separator: "/") + (path.hasSuffix("/") ? "/" : "")
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct InlineRenderer {
|
struct InlineRenderer {
|
||||||
let store: Store
|
let store: Store
|
||||||
let size: CGFloat
|
let size: CGFloat
|
||||||
let taskId: String?
|
let taskId: String?
|
||||||
var weight: CGFloat = 400
|
let weight: CGFloat
|
||||||
var dir = ""
|
let dir: String
|
||||||
|
private let root: String
|
||||||
|
|
||||||
|
init(store: Store, size: CGFloat, taskId: String?, weight: CGFloat = 400, dir: String = "") {
|
||||||
|
self.store = store
|
||||||
|
self.size = size
|
||||||
|
self.taskId = taskId
|
||||||
|
self.weight = weight
|
||||||
|
self.dir = dir
|
||||||
|
root = taskId.flatMap { store.findTask($0)?.worktreePath } ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
private struct Style {
|
private struct Style {
|
||||||
var bold = false
|
var bold = false
|
||||||
@@ -173,11 +219,16 @@ struct InlineRenderer {
|
|||||||
for item in items {
|
for item in items {
|
||||||
switch item {
|
switch item {
|
||||||
case .text(let t):
|
case .text(let t):
|
||||||
append(run(t, style), &out)
|
text(t, style, &out)
|
||||||
case .lineBreak:
|
case .lineBreak:
|
||||||
append(run("\n", style), &out)
|
append(run("\n", style), &out)
|
||||||
case .code(let c):
|
case .code(let c):
|
||||||
var a = run(c, style)
|
var s = style
|
||||||
|
if s.link == nil, let path = codeFile(c) {
|
||||||
|
s.link = path
|
||||||
|
s.color = Palette.accent
|
||||||
|
}
|
||||||
|
var a = run(c, s)
|
||||||
a.backgroundColor = Palette.surface
|
a.backgroundColor = Palette.surface
|
||||||
a.appKit.backgroundColor = NSColor(Palette.surface)
|
a.appKit.backgroundColor = NSColor(Palette.surface)
|
||||||
append(a, &out)
|
append(a, &out)
|
||||||
@@ -233,9 +284,82 @@ struct InlineRenderer {
|
|||||||
render(inner, s, &out)
|
render(inner, s, &out)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if let url = hrefFile(href) {
|
||||||
|
s.link = url
|
||||||
|
s.color = Palette.accent
|
||||||
|
render(inner, s, &out)
|
||||||
|
return
|
||||||
|
}
|
||||||
render(inner, style, &out)
|
render(inner, style, &out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func fileLink(_ path: String) -> URL? {
|
||||||
|
guard let taskId, !root.isEmpty, let p = normalPath(path) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return AppLink.file(taskId, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func codeFile(_ code: String) -> URL? {
|
||||||
|
var c = code
|
||||||
|
if c.hasPrefix(root + "/") {
|
||||||
|
c = String(c.dropFirst(root.count + 1))
|
||||||
|
if let m = rootedPath.exec(c), m[0] == c {
|
||||||
|
return fileLink(m[1]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codePath.exec(c).flatMap { fileLink($0[1]!) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hrefFile(_ href: String) -> URL? {
|
||||||
|
let bare = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map { String($0).replacingOccurrences(of: #":\d+(?:[-:]\d+)?$"#, with: "", options: .regularExpression) } ?? ""
|
||||||
|
if bare.isEmpty || anyScheme.test(bare) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard var p = bare.removingPercentEncoding else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p.hasPrefix("/") {
|
||||||
|
guard p.hasPrefix(root + "/") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p = String(p.dropFirst(root.count + 1))
|
||||||
|
} else if !dir.isEmpty {
|
||||||
|
p = "\(dir)/\(p)"
|
||||||
|
}
|
||||||
|
return fileLink(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func text(_ t: String, _ style: Style, _ out: inout [MDSegment]) {
|
||||||
|
if style.link != nil || root.isEmpty {
|
||||||
|
append(run(t, style), &out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rest = t
|
||||||
|
while !rest.isEmpty {
|
||||||
|
var found = barePath.match(rest).map { (range: $0.range, path: $0.groups[1]!) }
|
||||||
|
if let r = rest.range(of: root + "/"), found == nil || r.lowerBound <= found!.range.lowerBound {
|
||||||
|
let tail = String(rest[r.upperBound...])
|
||||||
|
if let m = rootedPath.match(tail) {
|
||||||
|
let end = rest.index(r.upperBound, offsetBy: tail.distance(from: tail.startIndex, to: m.range.upperBound))
|
||||||
|
found = (r.lowerBound..<end, m.groups[1]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let found else {
|
||||||
|
append(run(rest, style), &out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
append(run(String(rest[..<found.range.lowerBound]), style), &out)
|
||||||
|
var s = style
|
||||||
|
if let url = fileLink(found.path) {
|
||||||
|
s.link = url
|
||||||
|
s.color = Palette.accent
|
||||||
|
}
|
||||||
|
append(run(String(rest[found.range]), s), &out)
|
||||||
|
rest = String(rest[found.range.upperBound...])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func todo(_ projectId: String, _ title: String, _ brief: String?, _ inner: [MDInline], _ style: Style, _ out: inout [MDSegment]) {
|
private func todo(_ projectId: String, _ title: String, _ brief: String?, _ inner: [MDInline], _ style: Style, _ out: inout [MDSegment]) {
|
||||||
let task = (store.tasks[projectId] ?? []).first { $0.status == .active && (brief == nil ? $0.todoItem == title : $0.title == title) }
|
let task = (store.tasks[projectId] ?? []).first { $0.status == .active && (brief == nil ? $0.todoItem == title : $0.title == title) }
|
||||||
var s = style
|
var s = style
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ final class Store {
|
|||||||
private(set) var leadMore = false
|
private(set) var leadMore = false
|
||||||
var todoBusy: [String: String] = [:]
|
var todoBusy: [String: String] = [:]
|
||||||
var pickedMembers: [String: String] = [:]
|
var pickedMembers: [String: String] = [:]
|
||||||
|
var filePlaces: [String: FilePlace] = [:]
|
||||||
private(set) var drafts: Drafts?
|
private(set) var drafts: Drafts?
|
||||||
let images: ImageDrafts
|
let images: ImageDrafts
|
||||||
let imageCache = ImageCache()
|
let imageCache = ImageCache()
|
||||||
|
|||||||
Reference in New Issue
Block a user