Render descriptions with shared Markdown preview

Use one Markdown presentation for free-form content.

- issue and pull-request descriptions and comments
- milestone and commit descriptions
This commit is contained in:
Georg Bauer
2026-08-01 15:24:01 +02:00
parent 047f0ab0dc
commit d0ec533da6
4 changed files with 54 additions and 44 deletions

View File

@@ -192,6 +192,10 @@ xcrun simctl launch booted de.rfc1437.gotcha
Multiple labels wrap without clipping at large Dynamic Type sizes and Multiple labels wrap without clipping at large Dynamic Type sizes and
VoiceOver announces each label. Links and selectable text use normal iOS VoiceOver announces each label. Links and selectable text use normal iOS
interaction. interaction.
- [ ] Open issue #50 and verify its body renders separate paragraphs and a
two-item unordered list. Add or edit a comment containing two paragraphs,
an unordered list, emphasis, and a link; verify every Markdown block and
inline style renders correctly after saving and after pull-to-refresh.
- [ ] Tap the **Add comment** icon on an issue. Enter headings, emphasis, a - [ ] Tap the **Add comment** icon on an issue. Enter headings, emphasis, a
list, link, and fenced code block; **Write** syntax-highlights the list, link, and fenced code block; **Write** syntax-highlights the
Markdown, **Preview** renders it, and switching modes preserves the exact Markdown, **Preview** renders it, and switching modes preserves the exact
@@ -220,7 +224,9 @@ xcrun simctl launch booted de.rfc1437.gotcha
- [ ] Open a commit and verify its header shows the full title and description, - [ ] Open a commit and verify its header shows the full title and description,
full hash, author, committer, date, known branch/ref, and signature status full hash, author, committer, date, known branch/ref, and signature status
above the Changed Files paths and statuses. Unsigned commits say above the Changed Files paths and statuses. Unsigned commits say
**Unsigned**. Check that long values wrap and remain readable at the **Unsigned**. Verify paragraphs, lists, links, and inline styles in a
multi-line commit description use the repository file Preview Markdown
presentation. Check that long values wrap and remain readable at the
largest Dynamic Type accessibility size. largest Dynamic Type accessibility size.
- [ ] Open a changed file and verify the diff title, old/new line numbers, - [ ] Open a changed file and verify the diff title, old/new line numbers,
monospaced text, addition/removal/hunk colors, vertical scrolling, and monospaced text, addition/removal/hunk colors, vertical scrolling, and
@@ -300,6 +306,9 @@ xcrun simctl launch booted de.rfc1437.gotcha
- [ ] Each milestone shows its title, description, state, due date, issue - [ ] Each milestone shows its title, description, state, due date, issue
counts, and a green/amber closed/open progress bar. Lists and details show counts, and a green/amber closed/open progress bar. Lists and details show
a green open or purple closed icon and VoiceOver announces the state. a green open or purple closed icon and VoiceOver announces the state.
- [ ] A milestone description containing multiple paragraphs, a list, emphasis,
and a link uses the repository file Preview Markdown presentation in both
the milestone list and detail.
- [ ] Open a milestone and verify every assigned issue and pull request is - [ ] Open a milestone and verify every assigned issue and pull request is
listed; tapping either opens its normal detail. listed; tapping either opens its normal detail.
- [ ] Tap the add button. **New Milestone** has native Cancel and Save controls, - [ ] Tap the add button. **New Milestone** has native Cancel and Save controls,

View File

@@ -733,7 +733,8 @@ final class MilestoneViewController: RefreshingTableViewController {
final class MilestoneCell: UITableViewCell { final class MilestoneCell: UITableViewCell {
private let stateIcon = UIImageView() private let stateIcon = UIImageView()
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let descriptionLabel = UILabel() private let stack = UIStackView()
private var descriptionView: UIView?
private let metaLabel = UILabel() private let metaLabel = UILabel()
private let progress = UIProgressView(progressViewStyle: .bar) private let progress = UIProgressView(progressViewStyle: .bar)
@@ -744,16 +745,13 @@ final class MilestoneCell: UITableViewCell {
let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel]) let titleStack = UIStackView(arrangedSubviews: [stateIcon, titleLabel])
titleStack.alignment = .firstBaseline titleStack.alignment = .firstBaseline
titleStack.spacing = 8 titleStack.spacing = 8
descriptionLabel.font = .preferredFont(forTextStyle: .subheadline)
descriptionLabel.textColor = .secondaryLabel
descriptionLabel.numberOfLines = 2
metaLabel.font = .preferredFont(forTextStyle: .caption1) metaLabel.font = .preferredFont(forTextStyle: .caption1)
metaLabel.textColor = .tertiaryLabel metaLabel.textColor = .tertiaryLabel
metaLabel.numberOfLines = 2 metaLabel.numberOfLines = 2
progress.progressTintColor = .systemGreen progress.progressTintColor = .systemGreen
let stack = UIStackView( stack.addArrangedSubview(titleStack)
arrangedSubviews: [titleStack, descriptionLabel, progress, metaLabel] stack.addArrangedSubview(progress)
) stack.addArrangedSubview(metaLabel)
stack.axis = .vertical stack.axis = .vertical
stack.spacing = 7 stack.spacing = 7
stack.translatesAutoresizingMaskIntoConstraints = false stack.translatesAutoresizingMaskIntoConstraints = false
@@ -778,7 +776,12 @@ final class MilestoneCell: UITableViewCell {
textStyle: .headline textStyle: .headline
) )
titleLabel.text = row.title titleLabel.text = row.title
descriptionLabel.text = row.description descriptionView?.removeFromSuperview()
if !row.description.isEmpty {
let view = markdownView(row.description)
stack.insertArrangedSubview(view, at: 1)
descriptionView = view
}
metaLabel.text = row.meta metaLabel.text = row.meta
progress.progress = Float(row.progress) progress.progress = Float(row.progress)
progress.trackTintColor = row.hasIssues ? .systemOrange : .systemGray5 progress.trackTintColor = row.hasIssues ? .systemOrange : .systemGray5

View File

@@ -1,5 +1,4 @@
import Highlighter import Highlighter
import MarkdownUI
import QuickLook import QuickLook
import SwiftUI import SwiftUI
import UIKit import UIKit
@@ -402,7 +401,7 @@ private final class FileListView: UITableView, UITableViewDataSource, UITableVie
private final class CommitHeaderView: UIView { private final class CommitHeaderView: UIView {
private let stack = UIStackView() private let stack = UIStackView()
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let descriptionLabel = UILabel() private var descriptionView: UIView?
private let metadataStack = UIStackView() private let metadataStack = UIStackView()
override init(frame: CGRect) { override init(frame: CGRect) {
@@ -414,9 +413,6 @@ private final class CommitHeaderView: UIView {
titleLabel.adjustsFontForContentSizeCategory = true titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.numberOfLines = 0 titleLabel.numberOfLines = 0
titleLabel.accessibilityTraits = .header titleLabel.accessibilityTraits = .header
descriptionLabel.font = .preferredFont(forTextStyle: .body)
descriptionLabel.adjustsFontForContentSizeCategory = true
descriptionLabel.numberOfLines = 0
metadataStack.axis = .vertical metadataStack.axis = .vertical
metadataStack.spacing = 10 metadataStack.spacing = 10
@@ -424,7 +420,6 @@ private final class CommitHeaderView: UIView {
stack.spacing = 12 stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false stack.translatesAutoresizingMaskIntoConstraints = false
stack.addArrangedSubview(titleLabel) stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(descriptionLabel)
stack.addArrangedSubview(metadataStack) stack.addArrangedSubview(metadataStack)
addSubview(stack) addSubview(stack)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@@ -440,8 +435,12 @@ private final class CommitHeaderView: UIView {
func configure(_ page: CommitDetailsPage) { func configure(_ page: CommitDetailsPage) {
titleLabel.text = page.title titleLabel.text = page.title
descriptionLabel.text = page.description descriptionView?.removeFromSuperview()
descriptionLabel.isHidden = page.description.isEmpty if !page.description.isEmpty {
let view = markdownView(page.description)
stack.insertArrangedSubview(view, at: 1)
descriptionView = view
}
metadataStack.arrangedSubviews.forEach { $0.removeFromSuperview() } metadataStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for metadata in page.metadata { for metadata in page.metadata {
let isCommit = metadata.label == "Commit" let isCommit = metadata.label == "Commit"
@@ -1132,9 +1131,7 @@ struct RepositoryMarkdownPreview: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
Markdown(source) MarkdownContent(source: source)
.markdownTheme(.gitHub)
.frame(maxWidth: .infinity, alignment: .leading)
.padding() .padding()
} }
.background(Color(uiColor: .systemBackground)) .background(Color(uiColor: .systemBackground))
@@ -1235,6 +1232,7 @@ final class DiffViewController: UIViewController {
} }
} }
@MainActor
private func detailViews( private func detailViews(
title: String, title: String,
state: String? = nil, state: String? = nil,
@@ -1285,6 +1283,7 @@ private func stateTitle(_ titleLabel: UILabel, state: String, subject: String) -
return stack return stack
} }
@MainActor
private func commentViews( private func commentViews(
_ comments: [CommentRow], _ comments: [CommentRow],
editComment: ((CommentRow) -> Void)? = nil editComment: ((CommentRow) -> Void)? = nil
@@ -1323,19 +1322,6 @@ private func commentViews(
return views return views
} }
private func markdownView(_ source: String) -> UITextView {
let view = UITextView()
view.attributedText = markdown(source)
view.isEditable = false
view.isSelectable = true
view.isScrollEnabled = false
view.backgroundColor = .clear
view.textContainerInset = .zero
view.textContainer.lineFragmentPadding = 0
view.adjustsFontForContentSizeCategory = true
return view
}
private func sectionLabel(_ text: String) -> UILabel { private func sectionLabel(_ text: String) -> UILabel {
let label = UILabel() let label = UILabel()
label.text = text label.text = text

View File

@@ -1,5 +1,28 @@
import MarkdownUI
import SwiftUI
import UIKit import UIKit
struct MarkdownContent: View {
let source: String
var body: some View {
Markdown(source)
.markdownTheme(.gitHub)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@MainActor
func markdownView(_ source: String) -> UIView {
UIHostingConfiguration {
MarkdownContent(source: source)
.fixedSize(horizontal: false, vertical: true)
}
.margins(.all, 0)
.makeContentView()
}
func localDate(forUTCTimestamp timestamp: Int64) -> Date { func localDate(forUTCTimestamp timestamp: Int64) -> Date {
var utc = Calendar(identifier: .gregorian) var utc = Calendar(identifier: .gregorian)
utc.timeZone = TimeZone(secondsFromGMT: 0)! utc.timeZone = TimeZone(secondsFromGMT: 0)!
@@ -179,17 +202,6 @@ extension UIColor {
} }
} }
func markdown(_ source: String, textStyle: UIFont.TextStyle = .body) -> NSAttributedString {
let attributed = (try? AttributedString(
markdown: source,
options: .init(interpretedSyntax: .full)
)) ?? AttributedString(source)
var mutable = attributed
mutable.font = .preferredFont(forTextStyle: textStyle)
mutable.foregroundColor = UIColor.label
return NSAttributedString(mutable)
}
func configureTextCell(_ cell: UITableViewCell, title: String, detail: String, image: UIImage? = nil) { func configureTextCell(_ cell: UITableViewCell, title: String, detail: String, image: UIImage? = nil) {
var content = cell.defaultContentConfiguration() var content = cell.defaultContentConfiguration()
content.text = title content.text = title