From 4f9823129d99ed4d54cd2d96cf45792ffcb7223f Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sun, 18 Oct 2026 11:48:53 -0700 Subject: [PATCH] render markdown preview in the file browser --- docs/operations.md | 2 +- mac/Sources/Byre/Files.swift | 25 +++++++++++++++++++++++++ mac/Sources/Byre/Images.swift | 4 +++- mac/Sources/Byre/MarkdownView.swift | 14 ++++++++------ 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index 4be1417..7f253bb 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -344,7 +344,7 @@ 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. 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, 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. ## Terminals diff --git a/mac/Sources/Byre/Files.swift b/mac/Sources/Byre/Files.swift index 6b72dad..d059297 100644 --- a/mac/Sources/Byre/Files.swift +++ b/mac/Sources/Byre/Files.swift @@ -225,6 +225,9 @@ private struct FilesBody: View { } else if let c = model.content { if c.binary { EmptyText(text: "Binary file.") + } else if model.place.file?.lowercased().hasSuffix(".md") == true { + FileMarkdown(content: c, taskId: store.selectedTaskId, dir: model.place.dir) + .id(model.generation) } else { FileText(content: c) .id(model.generation) @@ -308,6 +311,28 @@ private struct EntryRow: View { } } +private struct FileMarkdown: View { + let content: FileContent + let taskId: String? + let dir: String + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + MarkdownView(blocks: parseMarkdown(content.text), ctx: MDContext(taskId: taskId, dir: dir)) + if content.truncated { + Text("Showing the first \(bytes(shown(content))) of \(bytes(content.size)).") + .foregroundStyle(Palette.muted) + .padding(.top, 12) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + } +} + private struct FileText: View { let content: FileContent diff --git a/mac/Sources/Byre/Images.swift b/mac/Sources/Byre/Images.swift index 220d59b..b9ab4f9 100644 --- a/mac/Sources/Byre/Images.swift +++ b/mac/Sources/Byre/Images.swift @@ -12,7 +12,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?) -> ImageSource? { +func worktreeImageSource(_ path: String, _ taskId: String?, dir: String = "") -> ImageSource? { if dataImage.test(path) { guard let comma = path.firstIndex(of: ","), path[.. ImageSource? { file = decoded } else if anyScheme.test(file) { return nil + } else if !dir.isEmpty && !file.hasPrefix("/") { + file = "\(dir)/\(file)" } guard let taskId else { return nil diff --git a/mac/Sources/Byre/MarkdownView.swift b/mac/Sources/Byre/MarkdownView.swift index 3072413..33a05bd 100644 --- a/mac/Sources/Byre/MarkdownView.swift +++ b/mac/Sources/Byre/MarkdownView.swift @@ -117,6 +117,7 @@ struct InlineRenderer { let size: CGFloat let taskId: String? var weight: CGFloat = 400 + var dir = "" private struct Style { var bold = false @@ -189,7 +190,7 @@ struct InlineRenderer { s.italic = true render(inner, s, &out) case .image(let src, let alt): - if let source = worktreeImageSource(src, taskId) { + if let source = worktreeImageSource(src, taskId, dir: dir) { out.append(.image(source, src: src)) } else { link(src, [.text(alt.isEmpty ? src : alt)], style, &out) @@ -287,6 +288,7 @@ struct MDContext { var choice: ((MDItem, Bool) -> AnyView)? var gridColumns = 4 var weight: CGFloat = 400 + var dir = "" } private enum MDRun { @@ -418,7 +420,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).text(inlines) + let a = InlineRenderer(store: store, size: size, taskId: ctx.taskId, weight: ctx.weight, dir: ctx.dir).text(inlines) return try! NSAttributedString(a, including: \.appKit) } @@ -585,7 +587,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).segments(inlines) + let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId, weight: ctx.weight, dir: ctx.dir).segments(inlines) VStack(alignment: .leading, spacing: 4) { ForEach(Array(segs.enumerated()), id: \.offset) { _, seg in switch seg { @@ -631,7 +633,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)) + InlineView(inlines: [.strong(inlines)], ctx: MDContext(size: level == 3 ? 14 : 13, color: ctx.color, taskId: ctx.taskId, dir: ctx.dir)) .padding(.top, 4) case .rule: Rectangle() @@ -639,7 +641,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)) + MarkdownView(blocks: blocks, ctx: MDContext(size: ctx.size, color: Palette.muted, taskId: ctx.taskId, dir: ctx.dir)) .padding(.leading, 13) .overlay(alignment: .leading) { Rectangle().fill(Palette.border).frame(width: 3) @@ -786,7 +788,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)) + InlineView(inlines: summary, ctx: MDContext(size: ctx.size, color: Palette.accent, taskId: ctx.taskId, dir: ctx.dir)) } .contentShape(Rectangle()) }