From fc07b5efd758b360b5816918d0b1aac225045c8f Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Mon, 19 Oct 2026 16:53:21 -0700 Subject: [PATCH] Link worktree file paths in Markdown to the Files tab --- docs/operations.md | 4 +- mac/Sources/Byre/Files.swift | 45 ++++++---- mac/Sources/Byre/Markdown.swift | 6 +- mac/Sources/Byre/MarkdownView.swift | 132 +++++++++++++++++++++++++++- mac/Sources/Byre/Store.swift | 1 + 5 files changed, 161 insertions(+), 27 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index cba892a..5f454bf 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 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 diff --git a/mac/Sources/Byre/Files.swift b/mac/Sources/Byre/Files.swift index d059297..b1e5eef 100644 --- a/mac/Sources/Byre/Files.swift +++ b/mac/Sources/Byre/Files.swift @@ -5,13 +5,26 @@ typealias DirListing = Components.Schemas.DirListing typealias DirEntry = Components.Schemas.DirEntry typealias FileContent = Components.Schemas.FileContent -private struct Place: Equatable { +struct FilePlace: Equatable { var dir = "" var file: String? } -@MainActor -private var places: [String: Place] = [:] +private struct FilesKey: Equatable { + 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 { dir.isEmpty ? name : "\(dir)/\(name)" @@ -50,22 +63,15 @@ private final class FilesModel { self.store = store } - var place: Place { - store.selectedTaskId.flatMap { places[$0] } ?? Place() + var place: FilePlace { + store.filePlace } - func go(_ p: Place) { + func go(_ p: FilePlace) { guard let taskId = store.selectedTaskId else { return } - places[taskId] = p - listing = nil - content = nil - error = "" - generation += 1 - Task { - await load() - } + store.filePlaces[taskId] = p } func reset() { @@ -74,6 +80,7 @@ private final class FilesModel { listing = nil content = nil error = "" + generation += 1 Task { await load() } @@ -159,7 +166,7 @@ struct FilesView: View { } } .frame(maxHeight: .infinity, alignment: .top) - .task(id: store.selectedTaskId) { + .task(id: FilesKey(taskId: store.selectedTaskId, place: store.filePlace)) { let m = model ?? FilesModel(store) model = m m.reset() @@ -204,7 +211,7 @@ private struct FilesHead: View { Text(label).foregroundStyle(Palette.text) } else { Button(label) { - model.go(Place(dir: dir)) + model.go(FilePlace(dir: dir)) } .buttonStyle(.plain) .foregroundStyle(Palette.accent) @@ -237,16 +244,16 @@ private struct FilesBody: View { VStack(alignment: .leading, spacing: 0) { if !l.path.isEmpty { 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 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) { 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 { - model.go(Place(dir: l.path, file: join(l.path, e.name))) + model.go(FilePlace(dir: l.path, file: join(l.path, e.name))) } } } diff --git a/mac/Sources/Byre/Markdown.swift b/mac/Sources/Byre/Markdown.swift index a9c0df5..eb4c6b3 100644 --- a/mac/Sources/Byre/Markdown.swift +++ b/mac/Sources/Byre/Markdown.swift @@ -85,7 +85,7 @@ private let quote = Re(#"^\s*>\s?(.*)$"#) private let rule = Re(#"^\s*([-*_])(\s*\1){2,}\s*$"#) private let imageOnly = Re(#"^\s*!\[[^\]]*\]\((<[^>]*>|[^)]*)\)\s*$"#) private let tableSep = Re(#"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$"#) -private let inlineToken = Re(#"`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?]+)>|([^)]+))\)|\[([^\]]+)\]\(([^)\s]+)\)|(https?://[^\s<>()]+[^\s<>().,;:!?'"])"#) +private let inlineToken = Re(#"`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?]+)>|([^)]+))\)|\[([^\]]+)\]\((?:<([^>]+)>|([^)\s]+))\)|(https?://[^\s<>()]+[^\s<>().,;:!?'"])"#) func parseMarkdown(_ src: String, choices: Bool = false) -> [MDBlock] { 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] { out.append(.image(src: trimmed(g[7] ?? g[8]!), alt: alt)) } else if let text = g[9] { - out.append(.link(href: g[10]!, spans(text))) - } else if let bare = g[11] { + out.append(.link(href: g[10] ?? g[11]!, spans(text))) + } else if let bare = g[12] { out.append(.link(href: bare, [.text(bare)])) } rest = String(rest[range.upperBound...]) diff --git a/mac/Sources/Byre/MarkdownView.swift b/mac/Sources/Byre/MarkdownView.swift index 5ce5fa6..707018c 100644 --- a/mac/Sources/Byre/MarkdownView.swift +++ b/mac/Sources/Byre/MarkdownView.swift @@ -18,6 +18,14 @@ enum AppLink { 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 { var c = URLComponents() c.scheme = "byre-app" @@ -44,6 +52,8 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct switch c.host { case "route": store.route(q["hash"] ?? "") + case "file": + store.openFile(q["task"] ?? "", q["path"] ?? "") case "todo": store.startTodo(q["project"] ?? "", q["title"] ?? "", discuss: q["discuss"] == "1", overlays) case "suggest": @@ -110,14 +120,50 @@ private let httpLink = Re(#"^https?://"#, caseless: true) private let taskHref = Re(#"^#/task/(\w+)(/\w+)?$"#) private let todoHref = Re(#"^#/todo/(\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(#"(? 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 struct InlineRenderer { let store: Store let size: CGFloat let taskId: String? - var weight: CGFloat = 400 - var dir = "" + let weight: CGFloat + 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 { var bold = false @@ -173,11 +219,16 @@ struct InlineRenderer { for item in items { switch item { case .text(let t): - append(run(t, style), &out) + text(t, style, &out) case .lineBreak: append(run("\n", style), &out) 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.appKit.backgroundColor = NSColor(Palette.surface) append(a, &out) @@ -233,9 +284,82 @@ struct InlineRenderer { render(inner, s, &out) return } + if let url = hrefFile(href) { + s.link = url + s.color = Palette.accent + render(inner, s, &out) + return + } 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..