render markdown preview in the file browser
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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[..<comma].lowercased().hasSuffix(";base64"), let data = Data(base64Encoded: String(path[path.index(after: comma)...])) else {
|
||||
return nil
|
||||
@@ -27,6 +27,8 @@ func worktreeImageSource(_ path: String, _ taskId: String?) -> 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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user