t3-code-android-nightly/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift

2749 lines
89 KiB
Swift

import ExpoModulesCore
import UIKit
private struct ReviewDiffNativeRow: Decodable, Sendable {
let kind: String
let id: String
let fileId: String?
let filePath: String?
let previousPath: String?
let changeType: String?
let additions: Int?
let deletions: Int?
let text: String?
let content: String?
let change: String?
let oldLineNumber: Int?
let newLineNumber: Int?
let wordDiffRanges: [ReviewDiffNativeWordDiffRange]?
let commentText: String?
let commentRangeLabel: String?
let commentSectionTitle: String?
}
private struct ReviewDiffNativeWordDiffRange: Decodable, Sendable {
let start: Int
let end: Int
}
private struct ReviewDiffNativeToken: Decodable, Sendable {
let content: String
let color: String?
let fontStyle: Int?
}
private struct ReviewDiffNativeTokenPatch: Decodable, Sendable {
let resetKey: String?
let chunkIndex: Int?
let tokensByRowId: [String: [ReviewDiffNativeToken]]?
}
private struct ReviewDiffNativeThemePayload: Decodable {
let background: String?
let text: String?
let mutedText: String?
let headerBackground: String?
let border: String?
let hunkBackground: String?
let hunkText: String?
let addBackground: String?
let deleteBackground: String?
let addBar: String?
let deleteBar: String?
let addText: String?
let deleteText: String?
}
private struct ReviewDiffNativeTheme {
let background: UIColor
let text: UIColor
let mutedText: UIColor
let headerBackground: UIColor
let border: UIColor
let hunkBackground: UIColor
let hunkText: UIColor
let addBackground: UIColor
let deleteBackground: UIColor
let addBar: UIColor
let deleteBar: UIColor
let addText: UIColor
let deleteText: UIColor
static func resolve(_ scheme: String) -> ReviewDiffNativeTheme {
resolve(scheme, payload: nil)
}
static func resolve(
_ scheme: String,
payload: ReviewDiffNativeThemePayload?
) -> ReviewDiffNativeTheme {
let fallback = fallback(scheme)
guard let payload else {
return fallback
}
return ReviewDiffNativeTheme(
background: UIColor(reviewDiffHex: payload.background) ?? fallback.background,
text: UIColor(reviewDiffHex: payload.text) ?? fallback.text,
mutedText: UIColor(reviewDiffHex: payload.mutedText) ?? fallback.mutedText,
headerBackground: UIColor(reviewDiffHex: payload.headerBackground) ?? fallback.headerBackground,
border: UIColor(reviewDiffHex: payload.border) ?? fallback.border,
hunkBackground: UIColor(reviewDiffHex: payload.hunkBackground) ?? fallback.hunkBackground,
hunkText: UIColor(reviewDiffHex: payload.hunkText) ?? fallback.hunkText,
addBackground: UIColor(reviewDiffHex: payload.addBackground) ?? fallback.addBackground,
deleteBackground: UIColor(reviewDiffHex: payload.deleteBackground) ?? fallback.deleteBackground,
addBar: UIColor(reviewDiffHex: payload.addBar) ?? fallback.addBar,
deleteBar: UIColor(reviewDiffHex: payload.deleteBar) ?? fallback.deleteBar,
addText: UIColor(reviewDiffHex: payload.addText) ?? fallback.addText,
deleteText: UIColor(reviewDiffHex: payload.deleteText) ?? fallback.deleteText
)
}
private static func fallback(_ scheme: String) -> ReviewDiffNativeTheme {
if scheme == "dark" {
return ReviewDiffNativeTheme(
background: UIColor(red: 0.07, green: 0.07, blue: 0.07, alpha: 1),
text: UIColor(red: 0.90, green: 0.90, blue: 0.90, alpha: 1),
mutedText: UIColor(red: 0.52, green: 0.52, blue: 0.52, alpha: 1),
headerBackground: UIColor(red: 0.10, green: 0.10, blue: 0.10, alpha: 1),
border: UIColor(red: 0.16, green: 0.16, blue: 0.16, alpha: 1),
hunkBackground: UIColor(red: 0.03, green: 0.14, blue: 0.18, alpha: 1),
hunkText: UIColor(red: 0.41, green: 0.82, blue: 1.00, alpha: 1),
addBackground: UIColor(red: 0.02, green: 0.16, blue: 0.10, alpha: 1),
deleteBackground: UIColor(red: 0.18, green: 0.05, blue: 0.08, alpha: 1),
addBar: UIColor(red: 0.02, green: 0.82, blue: 0.54, alpha: 1),
deleteBar: UIColor(red: 1.00, green: 0.36, blue: 0.50, alpha: 1),
addText: UIColor(red: 0.10, green: 0.84, blue: 0.56, alpha: 1),
deleteText: UIColor(red: 1.00, green: 0.38, blue: 0.52, alpha: 1)
)
}
return ReviewDiffNativeTheme(
background: UIColor.white,
text: UIColor(red: 0.16, green: 0.16, blue: 0.16, alpha: 1),
mutedText: UIColor(red: 0.47, green: 0.47, blue: 0.47, alpha: 1),
headerBackground: UIColor.white,
border: UIColor(red: 0.88, green: 0.88, blue: 0.90, alpha: 1),
hunkBackground: UIColor(red: 0.83, green: 0.92, blue: 0.98, alpha: 1),
hunkText: UIColor(red: 0.00, green: 0.45, blue: 0.74, alpha: 1),
addBackground: UIColor(red: 0.85, green: 0.94, blue: 0.92, alpha: 1),
deleteBackground: UIColor(red: 0.96, green: 0.85, blue: 0.90, alpha: 1),
addBar: UIColor(red: 0.02, green: 0.80, blue: 0.52, alpha: 1),
deleteBar: UIColor(red: 1.00, green: 0.34, blue: 0.48, alpha: 1),
addText: UIColor(red: 0.00, green: 0.56, blue: 0.34, alpha: 1),
deleteText: UIColor(red: 0.90, green: 0.10, blue: 0.24, alpha: 1)
)
}
}
private struct ReviewDiffNativeStylePayload: Decodable {
let wordWrap: Bool?
let rowHeight: Double?
let contentWidth: Double?
let changeBarWidth: Double?
let gutterWidth: Double?
let codePadding: Double?
let textVerticalInset: Double?
let fileHeaderHeight: Double?
let fileHeaderHorizontalMargin: Double?
let fileHeaderVerticalMargin: Double?
let fileHeaderCornerRadius: Double?
let fileHeaderHorizontalPadding: Double?
let fileHeaderPathRightPadding: Double?
let fileHeaderCountColumnWidth: Double?
let fileHeaderCountGap: Double?
let codeFontSize: Double?
let codeFontWeight: String?
let lineNumberFontSize: Double?
let lineNumberFontWeight: String?
let hunkFontSize: Double?
let hunkFontWeight: String?
let fileHeaderFontSize: Double?
let fileHeaderFontWeight: String?
let fileHeaderMetaFontSize: Double?
let fileHeaderMetaFontWeight: String?
let fileHeaderSubtextFontSize: Double?
let fileHeaderSubtextFontWeight: String?
let fileHeaderStatusFontSize: Double?
let fileHeaderStatusFontWeight: String?
let emptyStateFontSize: Double?
let emptyStateFontWeight: String?
}
private struct ReviewDiffNativeStyle {
let wordWrap: Bool
let rowHeight: CGFloat
let contentWidth: CGFloat
let changeBarWidth: CGFloat
let gutterWidth: CGFloat
let codePadding: CGFloat
let textVerticalInset: CGFloat
let fileHeaderHeight: CGFloat
let fileHeaderHorizontalMargin: CGFloat
let fileHeaderVerticalMargin: CGFloat
let fileHeaderCornerRadius: CGFloat
let fileHeaderHorizontalPadding: CGFloat
let fileHeaderPathRightPadding: CGFloat
let fileHeaderCountColumnWidth: CGFloat
let fileHeaderCountGap: CGFloat
let codeFontSize: CGFloat
let codeFontWeight: UIFont.Weight
let lineNumberFontSize: CGFloat
let lineNumberFontWeight: UIFont.Weight
let hunkFontSize: CGFloat
let hunkFontWeight: UIFont.Weight
let fileHeaderFontSize: CGFloat
let fileHeaderFontWeight: UIFont.Weight
let fileHeaderMetaFontSize: CGFloat
let fileHeaderMetaFontWeight: UIFont.Weight
let fileHeaderSubtextFontSize: CGFloat
let fileHeaderSubtextFontWeight: UIFont.Weight
let fileHeaderStatusFontSize: CGFloat
let fileHeaderStatusFontWeight: UIFont.Weight
let emptyStateFontSize: CGFloat
let emptyStateFontWeight: UIFont.Weight
static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle {
ReviewDiffNativeStyle(
wordWrap: payload?.wordWrap ?? false,
rowHeight: metric(payload?.rowHeight, fallback: 24),
contentWidth: metric(payload?.contentWidth, fallback: 2800),
changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4),
gutterWidth: metric(payload?.gutterWidth, fallback: 50),
codePadding: metric(payload?.codePadding, fallback: 8),
textVerticalInset: metric(payload?.textVerticalInset, fallback: 3),
fileHeaderHeight: metric(payload?.fileHeaderHeight, fallback: 54),
fileHeaderHorizontalMargin: metric(payload?.fileHeaderHorizontalMargin, fallback: 8),
fileHeaderVerticalMargin: metric(payload?.fileHeaderVerticalMargin, fallback: 6),
fileHeaderCornerRadius: metric(payload?.fileHeaderCornerRadius, fallback: 10),
fileHeaderHorizontalPadding: metric(payload?.fileHeaderHorizontalPadding, fallback: 12),
fileHeaderPathRightPadding: metric(payload?.fileHeaderPathRightPadding, fallback: 128),
fileHeaderCountColumnWidth: metric(payload?.fileHeaderCountColumnWidth, fallback: 42),
fileHeaderCountGap: metric(payload?.fileHeaderCountGap, fallback: 6),
codeFontSize: metric(payload?.codeFontSize, fallback: 12),
codeFontWeight: fontWeight(payload?.codeFontWeight, fallback: .bold),
lineNumberFontSize: metric(payload?.lineNumberFontSize, fallback: 11),
lineNumberFontWeight: fontWeight(payload?.lineNumberFontWeight, fallback: .bold),
hunkFontSize: metric(payload?.hunkFontSize, fallback: 12),
hunkFontWeight: fontWeight(payload?.hunkFontWeight, fallback: .bold),
fileHeaderFontSize: metric(payload?.fileHeaderFontSize, fallback: 13),
fileHeaderFontWeight: fontWeight(payload?.fileHeaderFontWeight, fallback: .bold),
fileHeaderMetaFontSize: metric(payload?.fileHeaderMetaFontSize, fallback: 12),
fileHeaderMetaFontWeight: fontWeight(payload?.fileHeaderMetaFontWeight, fallback: .bold),
fileHeaderSubtextFontSize: metric(payload?.fileHeaderSubtextFontSize, fallback: 11),
fileHeaderSubtextFontWeight: fontWeight(payload?.fileHeaderSubtextFontWeight, fallback: .medium),
fileHeaderStatusFontSize: metric(payload?.fileHeaderStatusFontSize, fallback: 10),
fileHeaderStatusFontWeight: fontWeight(payload?.fileHeaderStatusFontWeight, fallback: .semibold),
emptyStateFontSize: metric(payload?.emptyStateFontSize, fallback: 13),
emptyStateFontWeight: fontWeight(payload?.emptyStateFontWeight, fallback: .semibold)
)
}
private static func metric(_ value: Double?, fallback: CGFloat) -> CGFloat {
guard let value, value.isFinite, value > 0 else {
return fallback
}
return CGFloat(value)
}
private static func nonNegativeMetric(_ value: Double?, fallback: CGFloat) -> CGFloat {
guard let value, value.isFinite, value >= 0 else {
return fallback
}
return CGFloat(value)
}
private static func fontWeight(_ value: String?, fallback: UIFont.Weight) -> UIFont.Weight {
switch value?.lowercased() {
case "ultralight", "ultra-light":
return .ultraLight
case "thin":
return .thin
case "light":
return .light
case "regular":
return .regular
case "medium":
return .medium
case "semibold", "semi-bold":
return .semibold
case "bold":
return .bold
case "heavy":
return .heavy
case "black":
return .black
default:
return fallback
}
}
func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle {
ReviewDiffNativeStyle(
wordWrap: wordWrap,
rowHeight: rowHeight ?? self.rowHeight,
contentWidth: contentWidth ?? self.contentWidth,
changeBarWidth: changeBarWidth,
gutterWidth: gutterWidth,
codePadding: codePadding,
textVerticalInset: textVerticalInset,
fileHeaderHeight: fileHeaderHeight,
fileHeaderHorizontalMargin: fileHeaderHorizontalMargin,
fileHeaderVerticalMargin: fileHeaderVerticalMargin,
fileHeaderCornerRadius: fileHeaderCornerRadius,
fileHeaderHorizontalPadding: fileHeaderHorizontalPadding,
fileHeaderPathRightPadding: fileHeaderPathRightPadding,
fileHeaderCountColumnWidth: fileHeaderCountColumnWidth,
fileHeaderCountGap: fileHeaderCountGap,
codeFontSize: codeFontSize,
codeFontWeight: codeFontWeight,
lineNumberFontSize: lineNumberFontSize,
lineNumberFontWeight: lineNumberFontWeight,
hunkFontSize: hunkFontSize,
hunkFontWeight: hunkFontWeight,
fileHeaderFontSize: fileHeaderFontSize,
fileHeaderFontWeight: fileHeaderFontWeight,
fileHeaderMetaFontSize: fileHeaderMetaFontSize,
fileHeaderMetaFontWeight: fileHeaderMetaFontWeight,
fileHeaderSubtextFontSize: fileHeaderSubtextFontSize,
fileHeaderSubtextFontWeight: fileHeaderSubtextFontWeight,
fileHeaderStatusFontSize: fileHeaderStatusFontSize,
fileHeaderStatusFontWeight: fileHeaderStatusFontWeight,
emptyStateFontSize: emptyStateFontSize,
emptyStateFontWeight: emptyStateFontWeight
)
}
}
public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
private let payloadDecodeQueue = DispatchQueue(
label: "com.t3tools.review-diff.payload-decode",
qos: .userInitiated
)
private let scrollView = UIScrollView()
private let contentView = ReviewDiffContentView()
private var rows: [ReviewDiffNativeRow] = []
private var appearanceScheme: String = "light"
private var themePayload: ReviewDiffNativeThemePayload?
private var stylePayload: ReviewDiffNativeStylePayload?
private var rowHeightOverride: CGFloat?
private var contentWidthOverride: CGFloat?
private var lastMetricsDebugKey = ""
private var lastVisibleRangeDebugKey = ""
private var tokensResetKey = ""
private var contentResetKey = ""
private var initialRowIndex: Int?
private var hasAppliedInitialRowIndex = false
private var lastVisibleFileId: String?
private var pendingScrollFileId: String?
private var pendingScrollAnimated = false
private var isProgrammaticScrollActive = false
private var rowsDecodeGeneration = 0
private var tokensDecodeGeneration = 0
let onDebug = EventDispatcher()
let onVisibleFileChange = EventDispatcher()
let onToggleFile = EventDispatcher()
let onToggleViewedFile = EventDispatcher()
let onPressLine = EventDispatcher()
let onToggleComment = EventDispatcher()
let onPullToRefresh = EventDispatcher()
private let pullRefreshControl = UIRefreshControl()
public required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
clipsToBounds = true
backgroundColor = contentView.theme.background
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.delegate = self
scrollView.clipsToBounds = true
scrollView.alwaysBounceVertical = true
scrollView.alwaysBounceHorizontal = false
scrollView.showsVerticalScrollIndicator = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.backgroundColor = contentView.theme.background
pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged)
scrollView.refreshControl = pullRefreshControl
addSubview(scrollView)
contentView.backgroundColor = contentView.theme.background
contentView.onToggleFile = { [weak self] fileId in
self?.onToggleFile(["fileId": fileId])
}
contentView.onToggleViewedFile = { [weak self] fileId in
self?.onToggleViewedFile(["fileId": fileId])
}
contentView.onPressLine = { [weak self] payload in
self?.onPressLine(payload)
}
contentView.onToggleComment = { [weak self] commentId in
self?.onToggleComment(["commentId": commentId])
}
contentView.onDrawMetrics = { [weak self] metrics in
self?.emitDebug("draw-metrics", metrics)
}
scrollView.addSubview(contentView)
}
public override func layoutSubviews() {
super.layoutSubviews()
scrollView.frame = bounds
updateContentMetrics()
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating {
contentView.isVerticalScrollActive = true
}
updateViewportFrame()
}
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
// A direct gesture takes ownership from any interrupted programmatic jump.
// Resume visible-file events immediately so the inspector follows the finger.
isProgrammaticScrollActive = false
contentView.isVerticalScrollActive = true
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
guard !decelerate else {
return
}
finishVerticalScroll()
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
finishVerticalScroll()
}
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
finishVerticalScroll()
}
func setRowsJson(_ rowsJson: String) {
rowsDecodeGeneration += 1
let generation = rowsDecodeGeneration
payloadDecodeQueue.async { [weak self] in
guard let data = rowsJson.data(using: .utf8) else {
return
}
do {
let decodedRows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)
DispatchQueue.main.async { [weak self] in
guard let self, generation == self.rowsDecodeGeneration else {
return
}
self.contentView.prepareRows(decodedRows, on: self.payloadDecodeQueue, isCurrent: { [weak self] in
generation == self?.rowsDecodeGeneration
}, completion: { [weak self] in
guard let self, generation == self.rowsDecodeGeneration else { return }
self.rows = decodedRows
self.contentView.rows = decodedRows
self.hasAppliedInitialRowIndex = false
self.lastVisibleFileId = nil
self.emitDebug("rows-decoded", [
"rows": decodedRows.count,
"firstKind": decodedRows.first?.kind ?? "none",
])
self.updateContentMetrics()
self.applyPendingScrollIfNeeded()
})
}
} catch {
let message = error.localizedDescription
DispatchQueue.main.async { [weak self] in
guard let self, generation == self.rowsDecodeGeneration else {
return
}
self.rows = []
self.contentView.rows = []
self.hasAppliedInitialRowIndex = false
self.lastVisibleFileId = nil
self.pendingScrollFileId = nil
self.updateContentMetrics()
self.emitDebug("rows-decode-failed", ["error": message])
}
}
}
}
func setTokensJson(_ tokensJson: String) {
tokensDecodeGeneration += 1
let generation = tokensDecodeGeneration
payloadDecodeQueue.async { [weak self] in
guard let data = tokensJson.data(using: .utf8) else {
return
}
do {
let decodedTokens = try JSONDecoder().decode(
[String: [ReviewDiffNativeToken]].self,
from: data
)
DispatchQueue.main.async { [weak self] in
guard let self, generation == self.tokensDecodeGeneration else {
return
}
self.contentView.tokensByRowId = decodedTokens
}
} catch {
let message = error.localizedDescription
DispatchQueue.main.async { [weak self] in
guard let self, generation == self.tokensDecodeGeneration else {
return
}
self.contentView.tokensByRowId = [:]
self.emitDebug("tokens-decode-failed", ["error": message])
}
}
}
}
func setTokensPatchJson(_ tokensPatchJson: String) {
let contentResetKey = self.contentResetKey
payloadDecodeQueue.async { [weak self] in
guard let data = tokensPatchJson.data(using: .utf8) else {
return
}
do {
let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data)
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
guard contentResetKey == self.contentResetKey else {
return
}
// A highlighter request from the previous file can finish after the view has
// already reset. Never let that stale patch roll the native token state back.
if let resetKey = patch.resetKey, resetKey != self.tokensResetKey {
return
}
let tokensByRowId = patch.tokensByRowId ?? [:]
if tokensByRowId.isEmpty {
return
}
self.contentView.mergeTokensByRowId(tokensByRowId)
if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) {
self.emitDebug("tokens-patch-decoded", [
"chunkIndex": chunkIndex,
"rows": tokensByRowId.count,
"totalRows": self.contentView.tokensByRowId.count,
])
}
}
} catch {
let message = error.localizedDescription
DispatchQueue.main.async { [weak self] in
self?.emitDebug("tokens-patch-decode-failed", ["error": message])
}
}
}
}
func setTokensResetKey(_ tokensResetKey: String) {
guard tokensResetKey != self.tokensResetKey else {
return
}
self.tokensResetKey = tokensResetKey
contentView.tokensByRowId = [:]
emitDebug("tokens-reset", [
"resetKey": tokensResetKey,
])
}
// Code surfaces fill the screen so UIKit can sample content below the glass bar.
// Keep the same visible row when header or safe-area reservations change.
func setContentInsetTop(_ inset: CGFloat) {
scrollView.automaticallyAdjustsScrollIndicatorInsets = false
let inset = max(0, inset)
let delta = inset - scrollView.contentInset.top
guard delta != 0 else { return }
scrollView.contentInset.top = inset
contentView.topContentInset = inset
scrollView.verticalScrollIndicatorInsets.top = inset
scrollView.contentOffset.y -= delta
updateViewportFrame()
}
func setContentInsetBottom(_ inset: CGFloat) {
scrollView.contentInset.bottom = max(0, inset)
scrollView.verticalScrollIndicatorInsets.bottom = max(0, inset)
}
func setContentResetKey(_ contentResetKey: String) {
guard contentResetKey != self.contentResetKey else {
return
}
self.contentResetKey = contentResetKey
rowsDecodeGeneration += 1
tokensDecodeGeneration += 1
contentView.tokensByRowId = [:]
rows = []
contentView.rows = []
hasAppliedInitialRowIndex = false
pendingScrollFileId = nil
isProgrammaticScrollActive = false
scrollView.setContentOffset(CGPoint(x: 0, y: -scrollView.contentInset.top), animated: false)
updateContentMetrics()
updateViewportFrame()
lastVisibleFileId = nil
applyInitialRowIndexIfNeeded()
}
func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) {
let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson)
let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds)
let scrollAnchor: ReviewDiffScrollAnchor?
if changedFileIds.count == 1, let changedFileId = changedFileIds.first {
scrollAnchor = contentView.scrollAnchor(forFileId: changedFileId)
} else {
scrollAnchor = nil
}
contentView.collapsedFileIds = nextCollapsedFileIds
updateContentMetrics()
if let scrollAnchor,
let headerOffset = contentView.fileHeaderOffset(forFileId: scrollAnchor.fileId) {
let targetOffset = headerOffset - scrollAnchor.screenY
let minOffset = -scrollView.contentInset.top
let maxOffset = max(
scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom,
minOffset
)
let clampedOffset = min(max(targetOffset, minOffset), maxOffset)
scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: false)
updateViewportFrame()
}
}
func setViewedFileIdsJson(_ viewedFileIdsJson: String) {
contentView.viewedFileIds = decodeFileIdSet(viewedFileIdsJson)
}
func setSelectedRowIdsJson(_ selectedRowIdsJson: String) {
contentView.selectedRowIds = decodeFileIdSet(selectedRowIdsJson)
}
func setCollapsedCommentIdsJson(_ collapsedCommentIdsJson: String) {
contentView.collapsedCommentIds = decodeFileIdSet(collapsedCommentIdsJson)
updateContentMetrics()
}
private func decodeFileIdSet(_ json: String) -> Set<String> {
guard let data = json.data(using: .utf8) else {
return []
}
do {
return Set(try JSONDecoder().decode([String].self, from: data))
} catch {
emitDebug("file-id-set-decode-failed", [
"error": error.localizedDescription,
])
return []
}
}
func setAppearanceScheme(_ appearanceScheme: String) {
self.appearanceScheme = appearanceScheme
applyTheme()
}
func setThemeJson(_ themeJson: String) {
guard let data = themeJson.data(using: .utf8) else {
themePayload = nil
applyTheme()
return
}
do {
themePayload = try JSONDecoder().decode(ReviewDiffNativeThemePayload.self, from: data)
} catch {
themePayload = nil
emitDebug("theme-decode-failed", [
"error": error.localizedDescription,
])
}
applyTheme()
}
private func updateContentMetrics() {
let style = contentView.style
contentView.viewportWidth = bounds.width
let height = max(bounds.height, contentView.contentHeight)
let width = bounds.width
scrollView.contentSize = CGSize(width: bounds.width, height: height)
contentView.frame = CGRect(
x: 0,
y: scrollView.contentOffset.y,
width: max(width, 1),
height: max(bounds.height, 1)
)
contentView.verticalOffset = scrollView.contentOffset.y
contentView.invalidateVisibleViewport()
contentView.setNeedsDisplay()
applyInitialRowIndexIfNeeded()
applyPendingScrollIfNeeded()
emitVisibleFileIfNeeded()
let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))"
if debugKey != lastMetricsDebugKey {
lastMetricsDebugKey = debugKey
emitDebug("metrics", [
"rows": rows.count,
"boundsWidth": Double(bounds.width),
"boundsHeight": Double(bounds.height),
"contentHeight": Double(height),
"contentWidth": Double(style.contentWidth),
"fileHeaderHeight": Double(style.fileHeaderHeight),
"rowHeight": Double(style.rowHeight),
])
}
}
private func emitDebug(_ message: String, _ details: [String: Any]) {
var payload = details
payload["message"] = message
onDebug(payload)
}
private func finishVerticalScroll() {
isProgrammaticScrollActive = false
contentView.isVerticalScrollActive = false
updateViewportFrame()
emitVisibleRange(reason: "scroll-end")
}
private func emitVisibleFileIfNeeded() {
// Keep the explicit destination selected while UIKit animates through the files
// between the old and new offsets. Emitting every intermediate header forces a
// React render per crossing and makes the navigator visibly flash.
guard !isProgrammaticScrollActive else {
return
}
// The top of the combined diff is the explicit "All files" destination.
// Treat it as a first-class selection instead of immediately resolving the
// first file header and undoing the navigator's optimistic selection.
let visibleTop = scrollView.contentOffset.y + scrollView.contentInset.top
if visibleTop <= 0.5 {
guard lastVisibleFileId != nil else {
return
}
lastVisibleFileId = nil
onVisibleFileChange(["fileId": NSNull()])
return
}
guard let fileId = contentView.visibleFileId(atVerticalOffset: visibleTop),
fileId != lastVisibleFileId else {
return
}
lastVisibleFileId = fileId
onVisibleFileChange(["fileId": fileId])
}
private func emitVisibleRange(reason: String) {
guard let range = contentView.currentVisibleRowRange() else {
return
}
let debugKey = "\(range.firstRowIndex):\(range.lastRowIndex):\(Int(scrollView.bounds.height))"
guard debugKey != lastVisibleRangeDebugKey else {
return
}
lastVisibleRangeDebugKey = debugKey
emitDebug("visible-range", [
"reason": reason,
"firstRowIndex": range.firstRowIndex,
"lastRowIndex": range.lastRowIndex,
"totalRows": rows.count,
])
}
private func applyTheme() {
contentView.theme = ReviewDiffNativeTheme.resolve(appearanceScheme, payload: themePayload)
backgroundColor = contentView.theme.background
scrollView.backgroundColor = contentView.theme.background
contentView.backgroundColor = contentView.theme.background
contentView.invalidateVisibleViewport()
}
func setStyleJson(_ styleJson: String) {
guard let data = styleJson.data(using: .utf8) else {
stylePayload = nil
applyStyle()
return
}
do {
stylePayload = try JSONDecoder().decode(ReviewDiffNativeStylePayload.self, from: data)
} catch {
stylePayload = nil
emitDebug("style-decode-failed", [
"error": error.localizedDescription,
])
}
applyStyle()
}
func setRowHeight(_ rowHeight: CGFloat) {
rowHeightOverride = rowHeight.isFinite && rowHeight > 0 ? rowHeight : nil
applyStyle()
}
func setContentWidth(_ contentWidth: CGFloat) {
contentWidthOverride = contentWidth.isFinite && contentWidth > 0 ? contentWidth : nil
applyStyle()
}
func setInitialRowIndex(_ initialRowIndex: Double) {
let nextIndex: Int? = initialRowIndex.isFinite && initialRowIndex >= 0
? Int(initialRowIndex.rounded(.down))
: nil
guard nextIndex != self.initialRowIndex else {
return
}
self.initialRowIndex = nextIndex
hasAppliedInitialRowIndex = false
applyInitialRowIndexIfNeeded()
}
func scrollToFile(_ fileId: String, animated: Bool) {
pendingScrollFileId = fileId
pendingScrollAnimated = animated
applyPendingScrollIfNeeded()
}
func scrollToTop(animated: Bool) {
pendingScrollFileId = nil
pendingScrollAnimated = false
setVerticalContentOffset(0, animated: animated)
}
@objc private func handlePullToRefresh() {
onPullToRefresh([:])
}
/// Driven from JS: set to false once the reload completes to dismiss the spinner.
func setRefreshing(_ refreshing: Bool) {
if refreshing {
if !pullRefreshControl.isRefreshing {
pullRefreshControl.beginRefreshing()
}
} else if pullRefreshControl.isRefreshing {
pullRefreshControl.endRefreshing()
}
}
private func applyStyle() {
contentView.style = ReviewDiffNativeStyle
.resolve(stylePayload)
.applyingOverrides(rowHeight: rowHeightOverride, contentWidth: contentWidthOverride)
updateContentMetrics()
}
private func updateViewportFrame() {
contentView.frame = CGRect(
x: 0,
y: scrollView.contentOffset.y,
width: max(bounds.width, 1),
height: max(bounds.height, 1)
)
contentView.verticalOffset = scrollView.contentOffset.y
contentView.invalidateVisibleViewport()
emitVisibleFileIfNeeded()
}
private func applyPendingScrollIfNeeded() {
guard let fileId = pendingScrollFileId,
bounds.height > 0 else {
return
}
guard let headerOffset = contentView.fileHeaderOffset(forFileId: fileId) else {
if !rows.isEmpty {
pendingScrollFileId = nil
}
return
}
let animated = pendingScrollAnimated
pendingScrollFileId = nil
pendingScrollAnimated = false
setVerticalContentOffset(headerOffset, animated: animated)
}
private func setVerticalContentOffset(_ targetOffset: CGFloat, animated: Bool) {
let topInset = scrollView.contentInset.top
let maxOffset = max(
scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom,
-topInset
)
let clampedOffset = min(max(targetOffset - topInset, -topInset), maxOffset)
let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5
isProgrammaticScrollActive = shouldAnimate
contentView.isVerticalScrollActive = shouldAnimate
scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: shouldAnimate)
if !shouldAnimate {
updateViewportFrame()
}
}
private func applyInitialRowIndexIfNeeded() {
guard !hasAppliedInitialRowIndex,
let initialRowIndex,
bounds.height > 0,
let rowFrame = contentView.frameForRow(at: initialRowIndex) else {
return
}
let visibleHeight = bounds.height - scrollView.contentInset.top - scrollView.contentInset.bottom
let targetScreenY = max(0, (visibleHeight - rowFrame.height) * 0.3)
hasAppliedInitialRowIndex = true
setVerticalContentOffset(rowFrame.minY - targetScreenY, animated: false)
}
}
private enum ReviewDiffHorizontalPanKind {
case code
case fileHeaderPath
}
private struct ReviewDiffFileHeaderPathLayout {
let displayPath: String
let rect: CGRect
}
private struct ReviewDiffFileHeaderInteractiveRects {
let chevron: CGRect
let icon: CGRect
let checkbox: CGRect
}
private struct ReviewDiffStickyFileHeaderIndex {
let position: Int
let rowIndex: Int
}
private struct ReviewDiffStickyFileHeaderTarget {
let rowIndex: Int
let row: ReviewDiffNativeRow
let rect: CGRect
}
private struct ReviewDiffScrollAnchor {
let fileId: String
let screenY: CGFloat
}
private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
var rows: [ReviewDiffNativeRow] = [] {
didSet {
stopHorizontalDeceleration()
horizontalOffsetsByFileId.removeAll()
headerPathOffsetsByFileId.removeAll()
activePanFileId = nil
activePanKind = nil
codeDecorationVersion += 1
tokenAttributedStringsByRowId.removeAll()
rebuildRowLayout()
setNeedsDisplayForVisibleBounds()
}
}
var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] {
didSet {
codeDecorationVersion += 1
tokenAttributedStringsByRowId.removeAll()
clampHorizontalOffsets()
setNeedsDisplayForVisibleBounds()
}
}
func mergeTokensByRowId(_ tokensPatch: [String: [ReviewDiffNativeToken]]) {
tokensPatch.forEach { rowId, tokens in
tokensByRowId[rowId] = tokens
tokenAttributedStringsByRowId.removeValue(forKey: rowId)
}
clampHorizontalOffsets()
setNeedsDisplayForVisibleBounds()
}
var collapsedFileIds: Set<String> = [] {
didSet {
rebuildRowLayout()
clampHorizontalOffsets()
setNeedsDisplayForVisibleBounds()
}
}
var viewedFileIds: Set<String> = [] {
didSet {
setNeedsDisplayForVisibleBounds()
}
}
var selectedRowIds: Set<String> = [] {
didSet {
setNeedsDisplayForVisibleBounds()
}
}
var collapsedCommentIds: Set<String> = [] {
didSet {
rebuildRowLayout()
setNeedsDisplayForVisibleBounds()
}
}
var style = ReviewDiffNativeStyle.resolve(nil) {
didSet {
codeDecorationVersion += 1
tokenAttributedStringsByRowId.removeAll()
rebuildRowLayout()
clampHorizontalOffsets()
setNeedsDisplayForVisibleBounds()
}
}
var viewportWidth: CGFloat = 0 {
didSet {
// Wrapped rows take their height from the width, so a new width is a new layout.
if style.wordWrap, viewportWidth != oldValue {
rebuildRowLayout()
}
clampHorizontalOffsets()
setNeedsDisplayForVisibleBounds()
}
}
var verticalOffset: CGFloat = 0
var topContentInset: CGFloat = 0
var theme = ReviewDiffNativeTheme.resolve("light") {
didSet {
tokenColorsByHex.removeAll()
codeDecorationVersion += 1
tokenAttributedStringsByRowId.removeAll()
setNeedsDisplayForVisibleBounds()
}
}
private(set) var contentHeight: CGFloat = 0
private var rowOffsets: [CGFloat] = []
private var fileHeaderRowIndices: [Int] = []
private var contentWidthsByFileId: [String: CGFloat] = [:]
private var tokenColorsByHex: [String: UIColor] = [:]
private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:]
private var codeCharacterWidth: CGFloat = 8
/// Columns per visual line while word wrap is on; nil while code rows pan horizontally.
private var codeWrapColumns: Int?
/// Text geometry survives comment height changes; width, font, and content invalidate it.
private var codeLayoutsByRowId: [String: ReviewDiffCodeLayout] = [:]
private var codeLayoutWidth: CGFloat = 0
private var codeLayoutFont: UIFont?
private var codeDecorationVersion = 0
private var panStartHorizontalOffset: CGFloat = 0
private var activePanFileId: String?
private var activePanKind: ReviewDiffHorizontalPanKind?
private var horizontalOffsetsByFileId: [String: CGFloat] = [:]
private var headerPathOffsetsByFileId: [String: CGFloat] = [:]
private var decelerationDisplayLink: CADisplayLink?
private var deceleratingFileId: String?
private var horizontalVelocity: CGFloat = 0
private var lastDecelerationTimestamp: CFTimeInterval = 0
private var lastDrawMetricsTimestamp: CFTimeInterval = 0
var isVerticalScrollActive = false
var onToggleFile: ((String) -> Void)?
var onToggleViewedFile: ((String) -> Void)?
var onPressLine: (([String: Any]) -> Void)?
var onToggleComment: ((String) -> Void)?
var onDrawMetrics: (([String: Any]) -> Void)?
private var stickyWidth: CGFloat {
style.changeBarWidth + style.gutterWidth
}
private var codeStartX: CGFloat {
stickyWidth + style.codePadding
}
/// Height before word wrap. Laid-out rows use height(at:), which includes wrapped lines.
private func height(for row: ReviewDiffNativeRow) -> CGFloat {
if row.kind == "file" {
return style.fileHeaderHeight
}
if collapsedFileIds.contains(resolvedFileId(for: row)) {
return 0
}
if row.kind == "notice" {
return max(style.rowHeight * 2, 44)
}
if row.kind == "comment" {
return collapsedCommentIds.contains(row.id) ? 44 : 124
}
return style.rowHeight
}
/// Laid-out height, including wrapped lines. Requires a layout built from the current rows.
private func height(at index: Int) -> CGFloat {
let nextOffset = index + 1 < rowOffsets.count ? rowOffsets[index + 1] : contentHeight
return nextOffset - rowOffsets[index]
}
private var codeWrapLineHeight: CGFloat {
ceil(codeFont.lineHeight)
}
func frameForRow(at index: Int) -> CGRect? {
guard rows.indices.contains(index), rowOffsets.indices.contains(index) else {
return nil
}
return CGRect(
x: 0,
y: rowOffsets[index],
width: max(viewportWidth, 1),
height: height(at: index)
)
}
/// Shape new content on the existing decode worker before publishing rows to the UI.
func prepareRows(
_ rows: [ReviewDiffNativeRow],
on queue: DispatchQueue,
isCurrent: @escaping () -> Bool,
completion: @escaping () -> Void
) {
let font = codeFont
let width = viewportWidth - codeStartX - style.codePadding
let characterWidth = monospaceCharacterWidth(font: font)
guard style.wordWrap, width >= characterWidth, characterWidth > 0 else {
completion()
return
}
let cached = codeLayoutWidth == width && codeLayoutFont == font ? codeLayoutsByRowId : [:]
queue.async { [weak self] in
var layouts: [String: ReviewDiffCodeLayout] = [:]
for row in rows where row.kind == "line" {
guard let text = row.content else { continue }
if let previous = cached[row.id], previous.text == text {
layouts[row.id] = previous
} else {
layouts[row.id] = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth)
}
}
DispatchQueue.main.async { [weak self] in
guard let self, isCurrent() else { return }
if self.codeFont != font || self.viewportWidth - self.codeStartX - self.style.codePadding != width {
self.prepareRows(rows, on: queue, isCurrent: isCurrent, completion: completion)
return
}
self.codeLayoutWidth = width
self.codeLayoutFont = font
self.codeLayoutsByRowId = layouts
completion()
}
}
}
private func rebuildRowLayout() {
var nextOffsets: [CGFloat] = []
var nextFileHeaderRowIndices: [Int] = []
nextOffsets.reserveCapacity(rows.count)
var maxColumnCountsByFileId: [String: Int] = [:]
var nextCodeLayouts: [String: ReviewDiffCodeLayout] = [:]
var offset: CGFloat = 0
let font = codeFont
let characterWidth = monospaceCharacterWidth(font: font)
let wrapAvailableWidth = viewportWidth - codeStartX - style.codePadding
let wrapColumns = style.wordWrap && characterWidth > 0 && wrapAvailableWidth >= characterWidth
? Int(wrapAvailableWidth / characterWidth)
: nil
if codeLayoutWidth != wrapAvailableWidth || codeLayoutFont != font {
codeLayoutsByRowId.removeAll()
codeLayoutWidth = wrapAvailableWidth
codeLayoutFont = font
}
for (index, row) in rows.enumerated() {
nextOffsets.append(offset)
if row.kind == "file" {
nextFileHeaderRowIndices.append(index)
}
var rowHeight = height(for: row)
let fileId = resolvedFileId(for: row)
switch row.kind {
case "line":
// UTF-16 columns match the word diff ranges and the segments drawCodeLines draws.
let columnCount = row.content?.utf16.count ?? 0
maxColumnCountsByFileId[fileId] = max(maxColumnCountsByFileId[fileId] ?? 0, columnCount)
if wrapColumns != nil, let content = row.content {
let cached = codeLayoutsByRowId[row.id]
let layout: ReviewDiffCodeLayout
if let cached, cached.text == content {
layout = cached
} else {
layout = ReviewDiffCodeLayout(
text: content, font: font, width: wrapAvailableWidth, characterWidth: characterWidth
)
}
nextCodeLayouts[row.id] = layout
if rowHeight > 0 {
rowHeight = max(rowHeight, layout.firstLineHeight) + layout.extraHeight
}
}
case "hunk":
maxColumnCountsByFileId[fileId] = max(
maxColumnCountsByFileId[fileId] ?? 0,
row.text?.utf16.count ?? 0
)
default:
break
}
offset += rowHeight
}
codeCharacterWidth = characterWidth
codeWrapColumns = wrapColumns
codeLayoutsByRowId = nextCodeLayouts
contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in
let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2
return max(0, min(style.contentWidth, measuredWidth))
}
rowOffsets = nextOffsets
fileHeaderRowIndices = nextFileHeaderRowIndices
contentHeight = offset
}
private var codeFont: UIFont {
UIFont.monospacedSystemFont(ofSize: style.codeFontSize, weight: style.codeFontWeight)
}
private var lineNumberFont: UIFont {
UIFont.monospacedSystemFont(ofSize: style.lineNumberFontSize, weight: style.lineNumberFontWeight)
}
private var hunkFont: UIFont {
UIFont.monospacedSystemFont(ofSize: style.hunkFontSize, weight: style.hunkFontWeight)
}
private var fileHeaderFont: UIFont {
UIFont.systemFont(ofSize: style.fileHeaderFontSize, weight: style.fileHeaderFontWeight)
}
private var fileHeaderMetaFont: UIFont {
UIFont.systemFont(ofSize: style.fileHeaderMetaFontSize, weight: style.fileHeaderMetaFontWeight)
}
private var fileHeaderSubtextFont: UIFont {
UIFont.systemFont(ofSize: style.fileHeaderSubtextFontSize, weight: style.fileHeaderSubtextFontWeight)
}
private var fileHeaderStatusFont: UIFont {
UIFont.systemFont(ofSize: style.fileHeaderStatusFontSize, weight: style.fileHeaderStatusFontWeight)
}
private var emptyStateFont: UIFont {
UIFont.systemFont(ofSize: style.emptyStateFontSize, weight: style.emptyStateFontWeight)
}
private lazy var horizontalPanGesture: UIPanGestureRecognizer = {
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handleHorizontalPan(_:)))
gesture.delegate = self
gesture.cancelsTouchesInView = false
return gesture
}()
private lazy var tapGesture: UITapGestureRecognizer = {
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
gesture.delegate = self
gesture.cancelsTouchesInView = false
return gesture
}()
private lazy var longPressGesture: UILongPressGestureRecognizer = {
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
gesture.delegate = self
gesture.cancelsTouchesInView = false
gesture.minimumPressDuration = 0.28
return gesture
}()
override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = true
contentMode = .redraw
addGestureRecognizer(horizontalPanGesture)
addGestureRecognizer(tapGesture)
addGestureRecognizer(longPressGesture)
tapGesture.require(toFail: longPressGesture)
}
func invalidateVisibleViewport() {
setNeedsDisplayForVisibleBounds()
}
private func setNeedsDisplayForVisibleBounds() {
setNeedsDisplay()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
isOpaque = true
contentMode = .redraw
addGestureRecognizer(horizontalPanGesture)
addGestureRecognizer(tapGesture)
addGestureRecognizer(longPressGesture)
tapGesture.require(toFail: longPressGesture)
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer === horizontalPanGesture else {
return true
}
let velocity = horizontalPanGesture.velocity(in: self)
guard abs(velocity.x) > abs(velocity.y) * 1.25 else {
return false
}
guard let panTarget = horizontalPanTarget(at: horizontalPanGesture.location(in: self)) else {
return false
}
let currentOffset = horizontalOffset(for: panTarget.fileId, kind: panTarget.kind)
if velocity.x > 0 && currentOffset <= 0.5 {
return false
}
return maxHorizontalOffset(for: panTarget) > 0
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
guard gesture.state == .ended else {
return
}
let point = gesture.location(in: self)
if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) {
handleFileHeaderTap(row: stickyHeader.row, rect: stickyHeader.rect, point: point)
return
}
guard let rowIndex = rowIndex(at: verticalOffset + point.y),
rows.indices.contains(rowIndex) else {
return
}
let row = rows[rowIndex]
if row.kind == "comment" {
onToggleComment?(row.id)
return
}
if row.kind == "line" {
onPressLine?(linePressPayload(for: row, gesture: "tap"))
return
}
guard row.kind == "file" else {
return
}
let rowY = rowOffsets[rowIndex] - verticalOffset
let rect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row))
handleFileHeaderTap(row: row, rect: rect, point: point)
}
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
guard gesture.state == .began else {
return
}
let point = gesture.location(in: self)
if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) {
return
}
guard let rowIndex = rowIndex(at: verticalOffset + point.y),
rows.indices.contains(rowIndex) else {
return
}
let row = rows[rowIndex]
guard row.kind == "line" else {
return
}
onPressLine?(linePressPayload(for: row, gesture: "longPress"))
}
private func linePressPayload(for row: ReviewDiffNativeRow, gesture: String) -> [String: Any] {
var payload: [String: Any] = [
"rowId": row.id,
"fileId": resolvedFileId(for: row),
"gesture": gesture
]
if let oldLineNumber = row.oldLineNumber {
payload["oldLineNumber"] = oldLineNumber
}
if let newLineNumber = row.newLineNumber {
payload["newLineNumber"] = newLineNumber
}
if let change = row.change {
payload["change"] = change
}
return payload
}
private func handleFileHeaderTap(row: ReviewDiffNativeRow, rect: CGRect, point: CGPoint) {
let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: rect)
let fileId = resolvedFileId(for: row)
if interactiveRects.checkbox.contains(point) {
onToggleViewedFile?(fileId)
return
}
if rect.contains(point) {
onToggleFile?(fileId)
}
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
false
}
@objc private func handleHorizontalPan(_ gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .began:
stopHorizontalDeceleration()
let panTarget = horizontalPanTarget(at: gesture.location(in: self))
activePanFileId = panTarget?.fileId
activePanKind = panTarget?.kind
panStartHorizontalOffset = horizontalOffset(for: panTarget?.fileId, kind: panTarget?.kind)
case .changed, .ended, .cancelled:
guard let activePanFileId, let activePanKind else {
return
}
let translation = gesture.translation(in: self)
setHorizontalOffset(
min(
max(panStartHorizontalOffset - translation.x, 0),
maxHorizontalOffset(for: (fileId: activePanFileId, kind: activePanKind))
),
for: activePanFileId,
kind: activePanKind
)
if gesture.state == .ended, activePanKind == .code {
let velocity = -gesture.velocity(in: self).x
self.activePanFileId = nil
self.activePanKind = nil
startHorizontalDeceleration(fileId: activePanFileId, velocity: velocity)
} else if gesture.state == .cancelled {
self.activePanFileId = nil
self.activePanKind = nil
} else if gesture.state == .ended {
self.activePanFileId = nil
self.activePanKind = nil
}
default:
break
}
}
private func horizontalPanTarget(at point: CGPoint) -> (fileId: String, kind: ReviewDiffHorizontalPanKind)? {
if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) {
return (resolvedFileId(for: stickyHeader.row), .fileHeaderPath)
}
guard let row = row(at: point) else {
guard verticalOffset + point.y >= contentHeight,
fileHeaderRowIndices.isEmpty,
contentWidthsByFileId.count == 1,
let fileId = contentWidthsByFileId.keys.first,
!collapsedFileIds.contains(fileId) else {
return nil
}
return (fileId, .code)
}
let fileId = resolvedFileId(for: row)
if row.kind == "file" {
return (fileId, .fileHeaderPath)
}
return (fileId, .code)
}
private func stickyFileHeaderTarget() -> ReviewDiffStickyFileHeaderTarget? {
guard let stickyHeader = stickyFileHeaderRowIndex() else {
return nil
}
let rowIndex = stickyHeader.rowIndex
let headerTop = rowOffsets[rowIndex]
guard headerTop < verticalOffset + topContentInset else {
return nil
}
let nextHeaderPosition = stickyHeader.position + 1
let nextHeaderRowIndex = fileHeaderRowIndices.indices.contains(nextHeaderPosition)
? fileHeaderRowIndices[nextHeaderPosition]
: nil
let pushedY: CGFloat
if let nextHeaderRowIndex {
pushedY = min(topContentInset, rowOffsets[nextHeaderRowIndex] - verticalOffset - style.fileHeaderHeight)
} else {
pushedY = topContentInset
}
guard pushedY > topContentInset - style.fileHeaderHeight else {
return nil
}
let rect = CGRect(
x: 0,
y: pushedY,
width: max(bounds.width, viewportWidth),
height: style.fileHeaderHeight
)
return ReviewDiffStickyFileHeaderTarget(rowIndex: rowIndex, row: rows[rowIndex], rect: rect)
}
private func stickyFileHeaderRowIndex() -> ReviewDiffStickyFileHeaderIndex? {
guard !fileHeaderRowIndices.isEmpty else {
return nil
}
var lowerBound = 0
var upperBound = fileHeaderRowIndices.count
while lowerBound < upperBound {
let midpoint = (lowerBound + upperBound) / 2
let rowIndex = fileHeaderRowIndices[midpoint]
if rowOffsets[rowIndex] <= verticalOffset + topContentInset {
lowerBound = midpoint + 1
} else {
upperBound = midpoint
}
}
let matchIndex = lowerBound - 1
guard matchIndex >= 0 else {
return nil
}
return ReviewDiffStickyFileHeaderIndex(position: matchIndex, rowIndex: fileHeaderRowIndices[matchIndex])
}
func scrollAnchor(forFileId fileId: String) -> ReviewDiffScrollAnchor? {
if let stickyHeader = stickyFileHeaderTarget(),
resolvedFileId(for: stickyHeader.row) == fileId {
return ReviewDiffScrollAnchor(fileId: fileId, screenY: topContentInset)
}
guard let headerOffset = fileHeaderOffset(forFileId: fileId) else {
return nil
}
return ReviewDiffScrollAnchor(fileId: fileId, screenY: headerOffset - verticalOffset)
}
func fileHeaderOffset(forFileId fileId: String) -> CGFloat? {
guard let rowIndex = fileHeaderRowIndex(forFileId: fileId) else {
return nil
}
return rowOffsets[rowIndex]
}
func visibleFileId(atVerticalOffset verticalOffset: CGFloat) -> String? {
guard let firstHeaderRowIndex = fileHeaderRowIndices.first else {
return nil
}
var lowerBound = 0
var upperBound = fileHeaderRowIndices.count
while lowerBound < upperBound {
let midpoint = (lowerBound + upperBound) / 2
let rowIndex = fileHeaderRowIndices[midpoint]
if rowOffsets[rowIndex] <= verticalOffset + 0.5 {
lowerBound = midpoint + 1
} else {
upperBound = midpoint
}
}
let rowIndex = lowerBound > 0
? fileHeaderRowIndices[lowerBound - 1]
: firstHeaderRowIndex
guard rows.indices.contains(rowIndex) else {
return nil
}
return resolvedFileId(for: rows[rowIndex])
}
private func fileHeaderRowIndex(forFileId fileId: String) -> Int? {
fileHeaderRowIndices.first { rowIndex in
rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId
}
}
private func row(at point: CGPoint) -> ReviewDiffNativeRow? {
guard let rowIndex = rowIndex(at: verticalOffset + point.y) else {
return nil
}
guard rows.indices.contains(rowIndex) else {
return nil
}
return rows[rowIndex]
}
private func rowIndex(at absoluteY: CGFloat) -> Int? {
guard !rows.isEmpty else {
return nil
}
var lowerBound = 0
var upperBound = rows.count - 1
while lowerBound <= upperBound {
let midpoint = (lowerBound + upperBound) / 2
let rowStart = rowOffsets[midpoint]
let rowEnd = rowStart + height(at: midpoint)
if absoluteY < rowStart {
upperBound = midpoint - 1
} else if absoluteY >= rowEnd {
lowerBound = midpoint + 1
} else {
return midpoint
}
}
return nil
}
private func firstVisibleRowIndex(atOrAfter absoluteY: CGFloat) -> Int? {
guard !rows.isEmpty else {
return nil
}
var lowerBound = 0
var upperBound = rows.count
while lowerBound < upperBound {
let midpoint = (lowerBound + upperBound) / 2
let rowEnd = rowOffsets[midpoint] + height(at: midpoint)
if rowEnd < absoluteY {
lowerBound = midpoint + 1
} else {
upperBound = midpoint
}
}
return lowerBound < rows.count ? lowerBound : nil
}
private func lastVisibleRowIndex(atOrBefore absoluteY: CGFloat) -> Int? {
guard !rows.isEmpty else {
return nil
}
var lowerBound = 0
var upperBound = rows.count
while lowerBound < upperBound {
let midpoint = (lowerBound + upperBound) / 2
let rowStart = rowOffsets[midpoint]
if rowStart <= absoluteY {
lowerBound = midpoint + 1
} else {
upperBound = midpoint
}
}
let index = lowerBound - 1
return index >= 0 ? index : nil
}
private func resolvedFileId(for row: ReviewDiffNativeRow) -> String {
if let fileId = row.fileId {
return fileId
}
if let range = row.id.range(of: ":header") ?? row.id.range(of: ":hunk:") ?? row.id.range(of: ":line:") {
return String(row.id[..<range.lowerBound])
}
return row.filePath ?? row.id
}
private func horizontalOffset(
for fileId: String?,
kind: ReviewDiffHorizontalPanKind? = .code
) -> CGFloat {
guard let fileId else {
return 0
}
if kind == .fileHeaderPath {
return headerPathOffsetsByFileId[fileId] ?? 0
}
return horizontalOffsetsByFileId[fileId] ?? 0
}
private func setHorizontalOffset(
_ offset: CGFloat,
for fileId: String,
kind: ReviewDiffHorizontalPanKind = .code
) {
if kind == .fileHeaderPath {
headerPathOffsetsByFileId[fileId] = offset
setNeedsDisplayForVisibleBounds()
return
}
horizontalOffsetsByFileId[fileId] = offset
setNeedsDisplayForVisibleBounds()
}
private func clampHorizontalOffsets() {
for (fileId, offset) in horizontalOffsetsByFileId {
horizontalOffsetsByFileId[fileId] = min(offset, maxHorizontalOffset(for: (fileId: fileId, kind: .code)))
}
for (fileId, offset) in headerPathOffsetsByFileId {
headerPathOffsetsByFileId[fileId] = min(
offset,
maxHorizontalOffset(for: (fileId: fileId, kind: .fileHeaderPath))
)
}
}
private func maxHorizontalOffset(for target: (fileId: String, kind: ReviewDiffHorizontalPanKind)) -> CGFloat {
if target.kind == .fileHeaderPath,
let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) {
return maxHeaderPathOffset(for: row)
}
if codeWrapColumns != nil {
return 0
}
return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX))
}
private func contentWidth(for fileId: String) -> CGFloat {
contentWidthsByFileId[fileId] ?? min(style.contentWidth, max(viewportWidth, 0))
}
func currentVisibleRowRange() -> (firstRowIndex: Int, lastRowIndex: Int)? {
guard !rows.isEmpty else {
return nil
}
let visibleMinY = verticalOffset
let visibleMaxY = verticalOffset + max(bounds.height, 1)
guard let firstRowIndex = firstVisibleRowIndex(atOrAfter: visibleMinY),
let lastRowIndex = lastVisibleRowIndex(atOrBefore: visibleMaxY),
firstRowIndex <= lastRowIndex else {
return nil
}
return (firstRowIndex: firstRowIndex, lastRowIndex: lastRowIndex)
}
private func maxHeaderPathOffset(for row: ReviewDiffNativeRow) -> CGFloat {
let fullRect = CGRect(x: 0, y: 0, width: max(bounds.width, viewportWidth), height: height(for: row))
let layout = fileHeaderPathLayout(for: row, cardRect: fullRect)
let pathWidth = textWidth(layout.displayPath, font: fileHeaderFont)
return max(0, pathWidth - layout.rect.width)
}
private func startHorizontalDeceleration(fileId: String, velocity: CGFloat) {
guard abs(velocity) > 80 else {
return
}
let currentOffset = horizontalOffset(for: fileId)
let maxOffset = maxHorizontalOffset(for: (fileId: fileId, kind: .code))
if (currentOffset <= 0 && velocity < 0) || (currentOffset >= maxOffset && velocity > 0) {
return
}
stopHorizontalDeceleration()
deceleratingFileId = fileId
horizontalVelocity = velocity
lastDecelerationTimestamp = 0
let displayLink = CADisplayLink(target: self, selector: #selector(stepHorizontalDeceleration(_:)))
displayLink.add(to: .main, forMode: .common)
decelerationDisplayLink = displayLink
}
@objc private func stepHorizontalDeceleration(_ displayLink: CADisplayLink) {
guard let fileId = deceleratingFileId else {
stopHorizontalDeceleration()
return
}
if lastDecelerationTimestamp == 0 {
lastDecelerationTimestamp = displayLink.timestamp
return
}
let dt = max(0, displayLink.timestamp - lastDecelerationTimestamp)
lastDecelerationTimestamp = displayLink.timestamp
let maxOffset = maxHorizontalOffset(for: (fileId: fileId, kind: .code))
let nextOffset = horizontalOffset(for: fileId) + horizontalVelocity * CGFloat(dt)
let clampedOffset = min(max(nextOffset, 0), maxOffset)
setHorizontalOffset(clampedOffset, for: fileId)
// UIScrollView deceleration rates are expressed per millisecond.
horizontalVelocity *= CGFloat(pow(Double(UIScrollView.DecelerationRate.normal.rawValue), dt * 1000))
if abs(horizontalVelocity) < 20 || clampedOffset <= 0 || clampedOffset >= maxOffset {
stopHorizontalDeceleration()
}
}
private func stopHorizontalDeceleration() {
decelerationDisplayLink?.invalidate()
decelerationDisplayLink = nil
deceleratingFileId = nil
horizontalVelocity = 0
lastDecelerationTimestamp = 0
}
deinit {
stopHorizontalDeceleration()
}
override func draw(_ rect: CGRect) {
let drawStartedAt = CACurrentMediaTime()
guard let context = UIGraphicsGetCurrentContext() else {
return
}
theme.background.setFill()
context.fill(rect)
if rows.isEmpty {
drawEmptyState(rect)
return
}
let visibleMinY = verticalOffset + rect.minY
let visibleMaxY = verticalOffset + rect.maxY
let overscan = max(style.rowHeight, style.fileHeaderHeight) * 4
let rangeMinY = max(0, visibleMinY - overscan)
let rangeMaxY = visibleMaxY + overscan
guard let firstRowIndex = firstVisibleRowIndex(atOrAfter: rangeMinY),
let lastRowIndex = lastVisibleRowIndex(atOrBefore: rangeMaxY),
firstRowIndex <= lastRowIndex else {
return
}
var drawnRowCount = 0
for rowIndex in firstRowIndex...lastRowIndex {
let rowStart = rowOffsets[rowIndex]
let rowHeight = height(at: rowIndex)
if rowHeight <= 0 {
continue
}
let rowEnd = rowStart + rowHeight
if rowEnd < visibleMinY || rowStart > visibleMaxY {
continue
}
drawRow(rows[rowIndex], rowIndex: rowIndex, context: context)
drawnRowCount += 1
}
drawStickyFileHeader(context: context)
maybeEmitDrawMetrics(
drawnRowCount: drawnRowCount,
durationMs: (CACurrentMediaTime() - drawStartedAt) * 1000,
firstRowIndex: firstRowIndex,
lastRowIndex: lastRowIndex
)
}
private func maybeEmitDrawMetrics(
drawnRowCount: Int,
durationMs: Double,
firstRowIndex: Int,
lastRowIndex: Int
) {
guard !isVerticalScrollActive else {
return
}
let now = CACurrentMediaTime()
guard now - lastDrawMetricsTimestamp >= 1 else {
return
}
lastDrawMetricsTimestamp = now
onDrawMetrics?([
"drawnRows": drawnRowCount,
"durationMs": durationMs,
"firstRowIndex": firstRowIndex,
"lastRowIndex": lastRowIndex,
"scannedRows": lastRowIndex - firstRowIndex + 1,
"totalRows": rows.count,
])
}
private func drawEmptyState(_ rect: CGRect) {
let message = "No native diff rows"
let attributes: [NSAttributedString.Key: Any] = [
.font: emptyStateFont,
.foregroundColor: theme.mutedText,
]
message.draw(at: CGPoint(x: 16, y: rect.minY + 16), withAttributes: attributes)
}
private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) {
let rowY = rowOffsets[rowIndex] - verticalOffset
let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(at: rowIndex))
switch row.kind {
case "file":
drawFileRow(row, rect: fullRect, context: context)
case "hunk":
drawHunkRow(row, rect: fullRect, context: context)
case "notice":
drawNoticeRow(row, rect: fullRect, context: context)
case "comment":
drawCommentRow(row, rect: fullRect, context: context)
default:
drawCodeRow(row, rect: fullRect, context: context)
}
}
private func drawStickyFileHeader(context: CGContext) {
guard let stickyHeader = stickyFileHeaderTarget() else {
return
}
drawFileRow(stickyHeader.row, rect: stickyHeader.rect, context: context)
}
private func drawFileRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
theme.background.setFill()
context.fill(rect)
let cardRect = rect
theme.headerBackground.setFill()
context.fill(cardRect)
let hairline = 1 / traitCollection.displayScale
theme.border.setFill()
context.fill(CGRect(x: cardRect.minX, y: cardRect.maxY - hairline, width: cardRect.width, height: hairline))
let centerY = cardRect.midY
let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: cardRect)
let fileId = resolvedFileId(for: row)
drawDisclosureChevron(
rect: interactiveRects.chevron,
color: theme.mutedText,
collapsed: collapsedFileIds.contains(fileId)
)
drawFileIcon(rect: interactiveRects.icon, changeType: row.changeType)
drawViewedCheckbox(rect: interactiveRects.checkbox, checked: viewedFileIds.contains(fileId))
let deletions = row.deletions ?? 0
let additions = row.additions ?? 0
let deleteText = "-\(deletions)"
let addText = "+\(additions)"
let deleteWidth = textWidth(deleteText, font: fileHeaderMetaFont)
let addWidth = textWidth(addText, font: fileHeaderMetaFont)
let countsGap = min(style.fileHeaderCountGap, 4)
let countsWidth = deleteWidth + countsGap + addWidth
let countsX = interactiveRects.checkbox.minX - 10 - countsWidth
drawSingleLineText(
deleteText,
rect: CGRect(x: countsX, y: centerY - 9, width: deleteWidth, height: 18),
color: theme.deleteText,
font: fileHeaderMetaFont
)
drawSingleLineText(
addText,
rect: CGRect(x: countsX + deleteWidth + countsGap, y: centerY - 9, width: addWidth, height: 18),
color: theme.addText,
font: fileHeaderMetaFont
)
let pathLayout = fileHeaderPathLayout(for: row, cardRect: cardRect)
let pathOffset = horizontalOffset(for: resolvedFileId(for: row), kind: .fileHeaderPath)
drawSingleLineText(
pathLayout.displayPath,
rect: pathLayout.rect,
color: theme.text,
font: fileHeaderFont,
horizontalOffset: pathOffset
)
drawFileHeaderPathScrollFade(row, pathRect: pathLayout.rect, horizontalOffset: pathOffset, context: context)
}
private func drawNoticeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
guard !collapsedFileIds.contains(resolvedFileId(for: row)) else {
return
}
theme.background.setFill()
context.fill(rect)
let hairline = 1 / traitCollection.displayScale
theme.border.withAlphaComponent(0.65).setFill()
context.fill(CGRect(x: 0, y: rect.maxY - hairline, width: rect.width, height: hairline))
let iconSize: CGFloat = 16
let iconRect = CGRect(
x: style.fileHeaderHorizontalPadding + 2,
y: rect.midY - iconSize / 2,
width: iconSize,
height: iconSize
)
drawNoticeIcon(rect: iconRect, color: theme.mutedText)
drawSingleLineText(
row.text ?? "",
rect: CGRect(
x: iconRect.maxX + 10,
y: rect.midY - fileHeaderSubtextFont.lineHeight / 2,
width: max(24, viewportWidth - iconRect.maxX - 10 - style.fileHeaderHorizontalPadding),
height: fileHeaderSubtextFont.lineHeight
),
color: theme.mutedText,
font: fileHeaderSubtextFont
)
}
private func drawCommentRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
guard !collapsedFileIds.contains(resolvedFileId(for: row)) else {
return
}
theme.background.setFill()
context.fill(rect)
let isCollapsed = collapsedCommentIds.contains(row.id)
let cardInset = CGFloat(8)
let cardRect = rect.insetBy(dx: cardInset, dy: 5)
let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 10)
theme.headerBackground.setFill()
cardPath.fill()
theme.border.withAlphaComponent(0.85).setStroke()
cardPath.lineWidth = 1
cardPath.stroke()
let chevronRect = CGRect(x: cardRect.minX + 10, y: cardRect.minY + 11, width: 16, height: 16)
drawDisclosureChevron(rect: chevronRect, color: theme.mutedText, collapsed: isCollapsed)
let title = "Comment on \(row.commentRangeLabel ?? "line")"
drawSingleLineText(
title,
rect: CGRect(
x: chevronRect.maxX + 10,
y: cardRect.minY + 8,
width: max(24, cardRect.width - chevronRect.maxX - 28),
height: fileHeaderSubtextFont.lineHeight + 4
),
color: theme.mutedText,
font: fileHeaderSubtextFont
)
guard !isCollapsed else {
return
}
let body = row.commentText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
drawMultilineText(
body.isEmpty ? "Comment" : body,
rect: CGRect(
x: cardRect.minX + 18,
y: cardRect.minY + 42,
width: max(24, cardRect.width - 36),
height: max(20, cardRect.height - 56)
),
color: theme.text,
font: fileHeaderSubtextFont,
maximumLineCount: 3
)
}
private func fileHeaderPathLayout(
for row: ReviewDiffNativeRow,
cardRect: CGRect
) -> ReviewDiffFileHeaderPathLayout {
let centerY = cardRect.midY
let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: cardRect)
let deletions = row.deletions ?? 0
let additions = row.additions ?? 0
let deleteWidth = textWidth("-\(deletions)", font: fileHeaderMetaFont)
let addWidth = textWidth("+\(additions)", font: fileHeaderMetaFont)
let countsGap = min(style.fileHeaderCountGap, 4)
let countsWidth = deleteWidth + countsGap + addWidth
let countsX = interactiveRects.checkbox.minX - 10 - countsWidth
let pathX = interactiveRects.icon.maxX + 10
let pathWidth = max(CGFloat(24), countsX - pathX - 12)
let displayPath: String
if let previousPath = row.previousPath, previousPath != row.filePath, let filePath = row.filePath {
displayPath = "\(previousPath) -> \(filePath)"
} else {
displayPath = row.filePath ?? ""
}
return ReviewDiffFileHeaderPathLayout(
displayPath: displayPath,
rect: CGRect(x: pathX, y: centerY - 10, width: pathWidth, height: 20)
)
}
private func fileHeaderInteractiveRects(
for row: ReviewDiffNativeRow,
cardRect: CGRect
) -> ReviewDiffFileHeaderInteractiveRects {
let centerY = cardRect.midY
let horizontalPadding = style.fileHeaderHorizontalPadding
let chevronRect = CGRect(x: cardRect.minX + horizontalPadding, y: centerY - 10, width: 20, height: 20)
let iconRect = CGRect(x: chevronRect.maxX + 8, y: centerY - 10, width: 20, height: 20)
let checkboxRect = CGRect(x: cardRect.maxX - horizontalPadding - 20, y: centerY - 10, width: 20, height: 20)
return ReviewDiffFileHeaderInteractiveRects(
chevron: chevronRect,
icon: iconRect,
checkbox: checkboxRect
)
}
private func fileStatusColor(_ changeType: String?) -> UIColor {
switch changeType {
case "new":
return theme.addText
case "deleted":
return theme.deleteText
case "renamed", "rename-pure", "rename-changed":
return theme.hunkText
default:
return theme.hunkText
}
}
private func drawFileIcon(rect: CGRect, changeType: String?) {
let color = fileStatusColor(changeType)
let outerPath = UIBezierPath(roundedRect: rect, cornerRadius: 6)
color.setStroke()
outerPath.lineWidth = 2
outerPath.stroke()
if isRenameChange(changeType) {
drawRenameChevronIcon(rect: rect.insetBy(dx: 4.5, dy: 5), color: color)
return
}
let dotRect = CGRect(x: rect.midX - 3, y: rect.midY - 3, width: 6, height: 6)
color.setFill()
UIBezierPath(ovalIn: dotRect).fill()
}
private func isRenameChange(_ changeType: String?) -> Bool {
changeType == "renamed" || changeType == "rename-pure" || changeType == "rename-changed"
}
private func drawDisclosureChevron(rect: CGRect, color: UIColor, collapsed: Bool) {
guard let context = UIGraphicsGetCurrentContext() else {
return
}
context.saveGState()
context.setStrokeColor(color.cgColor)
context.setLineWidth(2)
context.setLineCap(.round)
context.setLineJoin(.round)
if collapsed {
context.move(to: CGPoint(x: rect.minX + rect.width * 0.40, y: rect.minY + rect.height * 0.28))
context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.60, y: rect.midY))
context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.40, y: rect.maxY - rect.height * 0.28))
} else {
context.move(to: CGPoint(x: rect.minX + rect.width * 0.28, y: rect.minY + rect.height * 0.42))
context.addLine(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.62))
context.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.28, y: rect.minY + rect.height * 0.42))
}
context.strokePath()
context.restoreGState()
}
private func drawRenameChevronIcon(rect: CGRect, color: UIColor) {
guard let context = UIGraphicsGetCurrentContext() else {
return
}
context.saveGState()
context.setStrokeColor(color.cgColor)
context.setLineWidth(1.8)
context.setLineCap(.round)
context.setLineJoin(.round)
let chevronWidth = min(rect.width * 0.28, 3.6)
let chevronHeight = min(rect.height, 8)
let gap = min(rect.width * 0.18, 2.4)
let totalWidth = chevronWidth * 2 + gap
let startX = rect.midX - totalWidth / 2
let topY = rect.midY - chevronHeight / 2
let bottomY = rect.midY + chevronHeight / 2
for x in [startX, startX + chevronWidth + gap] {
context.move(to: CGPoint(x: x, y: topY))
context.addLine(to: CGPoint(x: x + chevronWidth, y: rect.midY))
context.addLine(to: CGPoint(x: x, y: bottomY))
}
context.strokePath()
context.restoreGState()
}
private func drawViewedCheckbox(rect: CGRect, checked: Bool) {
let path = UIBezierPath(roundedRect: rect, cornerRadius: 6)
if checked {
theme.hunkText.setFill()
path.fill()
}
(checked ? theme.hunkText : theme.mutedText).setStroke()
path.lineWidth = 1.8
path.stroke()
guard checked, let context = UIGraphicsGetCurrentContext() else {
return
}
context.saveGState()
context.setStrokeColor(theme.background.cgColor)
context.setLineWidth(2)
context.setLineCap(.round)
context.setLineJoin(.round)
context.move(to: CGPoint(x: rect.minX + rect.width * 0.28, y: rect.midY))
context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.44, y: rect.maxY - rect.height * 0.30))
context.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.25, y: rect.minY + rect.height * 0.30))
context.strokePath()
context.restoreGState()
}
private func drawNoticeIcon(rect: CGRect, color: UIColor) {
guard let context = UIGraphicsGetCurrentContext() else {
return
}
context.saveGState()
context.setStrokeColor(color.cgColor)
context.setLineWidth(1.7)
context.setLineCap(.round)
context.setLineJoin(.round)
context.strokeEllipse(in: rect.insetBy(dx: 1, dy: 1))
context.move(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.30))
context.addLine(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.58))
context.strokePath()
color.setFill()
context.fillEllipse(in: CGRect(x: rect.midX - 1, y: rect.maxY - rect.height * 0.30, width: 2, height: 2))
context.restoreGState()
}
private func drawCenteredText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color,
.paragraphStyle: paragraphStyle,
]
(text as NSString).draw(in: rect, withAttributes: attributes)
}
private func drawHunkRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
let fileId = resolvedFileId(for: row)
let horizontalOffset = horizontalOffset(for: fileId)
let contentWidth = contentWidth(for: fileId)
theme.hunkBackground.setFill()
context.fill(rect)
context.saveGState()
context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight))
drawText(
row.text ?? "",
rect: CGRect(
x: codeStartX - horizontalOffset,
y: centeredTextY(in: rect, font: hunkFont),
width: contentWidth,
height: hunkFont.lineHeight
),
color: theme.hunkText,
font: hunkFont
)
context.restoreGState()
}
private func drawCodeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
let fileId = resolvedFileId(for: row)
let horizontalOffset = horizontalOffset(for: fileId)
let contentWidth = contentWidth(for: fileId)
let change = row.change ?? "context"
// Wrapped rows keep the line number and first code line in the first row-height band.
let layout = codeLayoutsByRowId[row.id]
let firstLineRect = CGRect(
x: rect.minX, y: rect.minY, width: rect.width,
height: max(style.rowHeight, layout?.firstLineHeight ?? 0)
)
rowBackground(for: change).setFill()
context.fill(rect)
if change == "add" {
theme.addBar.setFill()
context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height))
} else if change == "delete" {
drawDeleteStripes(
rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height),
context: context
)
}
drawSelectionOverlay(row, rect: rect, context: context)
let lineNumber = row.newLineNumber ?? row.oldLineNumber
if let lineNumber {
drawRightAlignedText(
"\(lineNumber)",
rect: CGRect(
x: style.changeBarWidth,
y: centeredTextY(in: firstLineRect, font: lineNumberFont),
width: style.gutterWidth - style.codePadding,
height: lineNumberFont.lineHeight
),
color: lineNumberColor(for: change),
font: lineNumberFont
)
}
context.saveGState()
context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: rect.height))
let codeTextRect = CGRect(
x: codeStartX - horizontalOffset,
y: centeredTextY(in: firstLineRect, font: codeFont),
width: contentWidth,
height: codeFont.lineHeight
)
if let layout, layout.usesNativeLayout {
let text = tokensByRowId[row.id].map {
tokenAttributedString(rowId: row.id, tokens: $0, fallbackColor: theme.text, font: codeFont)
} ?? NSAttributedString(string: row.content ?? "", attributes: [.foregroundColor: theme.text])
let highlights = (change == "add" || change == "delete") ? (row.wordDiffRanges ?? []) : []
layout.decorate(
text: text,
highlights: highlights.filter { $0.start >= 0 && $0.end > $0.start }.map {
NSRange(location: $0.start, length: $0.end - $0.start)
},
color: (change == "add" ? theme.addBar : theme.deleteBar).withAlphaComponent(0.28),
version: codeDecorationVersion
)
layout.draw(
at: CGPoint(x: codeStartX, y: rect.minY + max(0, (firstLineRect.height - layout.firstLineHeight) / 2)),
clip: context.boundingBoxOfClipPath
)
context.restoreGState()
return
}
let lineStarts = layout?.starts ?? [0]
drawWordDiffRanges(
row,
lineStarts: lineStarts,
firstLineRect: firstLineRect,
context: context,
horizontalOffset: horizontalOffset
)
if let tokens = tokensByRowId[row.id], !tokens.isEmpty {
let attributedText = tokenAttributedString(
rowId: row.id,
tokens: tokens,
fallbackColor: theme.text,
font: codeFont
)
drawCodeLines(length: attributedText.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in
let segment = range.length == attributedText.length
? attributedText
: attributedText.attributedSubstring(from: range)
segment.draw(in: lineRect)
}
} else {
let content = (row.content ?? "") as NSString
drawCodeLines(length: content.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in
let segment = range.length == content.length ? content as String : content.substring(with: range)
drawText(segment, rect: lineRect, color: theme.text, font: codeFont)
}
}
context.restoreGState()
}
/// Draws the segment starting at each of the row's line starts on its own visual line.
private func drawCodeLines(
length: Int,
lineStarts: [Int],
firstLineRect: CGRect,
draw: (NSRange, CGRect) -> Void
) {
var lineRect = firstLineRect
let clip = UIGraphicsGetCurrentContext()?.boundingBoxOfClipPath ?? bounds
let first = max(0, Int(floor((clip.minY - firstLineRect.minY) / codeWrapLineHeight)))
let last = min(lineStarts.count, Int(ceil((clip.maxY - firstLineRect.minY) / codeWrapLineHeight)))
guard first < last else { return }
lineRect.origin.y += CGFloat(first) * codeWrapLineHeight
for line in first..<last {
let lineStart = lineStarts[line]
let lineEnd = line + 1 < lineStarts.count ? lineStarts[line + 1] : length
draw(NSRange(location: lineStart, length: lineEnd - lineStart), lineRect)
lineRect.origin.y += codeWrapLineHeight
}
}
private func drawSelectionOverlay(
_ row: ReviewDiffNativeRow,
rect: CGRect,
context: CGContext
) {
guard selectedRowIds.contains(row.id) else {
return
}
let selectionColor = theme.hunkText.withAlphaComponent(0.22)
selectionColor.setFill()
context.fill(rect)
theme.hunkText.withAlphaComponent(0.95).setFill()
context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height))
}
private func drawWordDiffRanges(
_ row: ReviewDiffNativeRow,
lineStarts: [Int],
firstLineRect: CGRect,
context: CGContext,
horizontalOffset: CGFloat
) {
guard let ranges = row.wordDiffRanges, !ranges.isEmpty else {
return
}
let change = row.change ?? "context"
guard change == "add" || change == "delete" else {
return
}
let fillColor: UIColor
if change == "add" {
fillColor = theme.addBar.withAlphaComponent(0.28)
} else {
fillColor = theme.deleteBar.withAlphaComponent(0.28)
}
let highlightHeight = max(4, min(firstLineRect.height - 4, codeFont.lineHeight))
let highlightY = firstLineRect.midY - highlightHeight / 2
fillColor.setFill()
for range in ranges {
guard range.end > range.start else {
continue
}
// A wrapped row splits the highlight at each visual line boundary.
for (line, lineStart) in lineStarts.enumerated() {
let lineEnd = line + 1 < lineStarts.count ? lineStarts[line + 1] : Int.max
let start = max(range.start, lineStart)
let end = min(range.end, lineEnd)
guard end > start else {
continue
}
let highlightRect = CGRect(
x: codeStartX - horizontalOffset + CGFloat(start - lineStart) * codeCharacterWidth,
y: highlightY + CGFloat(line) * codeWrapLineHeight,
width: max(2, CGFloat(end - start) * codeCharacterWidth),
height: highlightHeight
)
UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill()
}
}
}
private func rowBackground(for change: String) -> UIColor {
if change == "add" {
return theme.addBackground
}
if change == "delete" {
return theme.deleteBackground
}
return theme.background
}
private func lineNumberColor(for change: String) -> UIColor {
if change == "add" {
return theme.addText
}
if change == "delete" {
return theme.deleteText
}
return theme.mutedText
}
private func drawDeleteStripes(rect: CGRect, context: CGContext) {
theme.deleteBar.setFill()
var y = rect.minY
while y < rect.maxY {
context.fill(CGRect(x: rect.minX, y: y, width: rect.width, height: 1))
y += 2
}
}
private func drawText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color,
.ligature: 0,
]
(text as NSString).draw(in: rect, withAttributes: attributes)
}
private func drawMultilineText(
_ text: String,
rect: CGRect,
color: UIColor,
font: UIFont,
maximumLineCount: Int
) {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineBreakMode = .byTruncatingTail
paragraphStyle.lineSpacing = 2
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color,
.paragraphStyle: paragraphStyle,
.ligature: 0,
]
let maxHeight = CGFloat(maximumLineCount) * (font.lineHeight + paragraphStyle.lineSpacing)
(text as NSString).draw(
in: CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: min(rect.height, maxHeight)),
withAttributes: attributes
)
}
private func centeredTextY(in rect: CGRect, font: UIFont) -> CGFloat {
rect.midY - font.lineHeight / 2
}
private func drawSingleLineText(
_ text: String,
rect: CGRect,
color: UIColor,
font: UIFont,
horizontalOffset: CGFloat = 0
) {
guard let context = UIGraphicsGetCurrentContext() else {
return
}
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color,
.ligature: 0,
]
context.saveGState()
context.clip(to: rect)
let textY = rect.midY - font.lineHeight / 2
(text as NSString).draw(
at: CGPoint(x: rect.minX - horizontalOffset, y: textY),
withAttributes: attributes
)
context.restoreGState()
}
private func drawFileHeaderPathScrollFade(
_ row: ReviewDiffNativeRow,
pathRect: CGRect,
horizontalOffset: CGFloat,
context: CGContext
) {
let maxOffset = maxHeaderPathOffset(for: row)
guard maxOffset > 0, pathRect.width > 0 else {
return
}
let fadeWidth = min(CGFloat(28), pathRect.width / 3)
if horizontalOffset > 0.5 {
drawHorizontalFade(
rect: CGRect(x: pathRect.minX, y: pathRect.minY, width: fadeWidth, height: pathRect.height),
color: theme.headerBackground,
fadesToRight: false,
context: context
)
}
if horizontalOffset < maxOffset - 0.5 {
drawHorizontalFade(
rect: CGRect(x: pathRect.maxX - fadeWidth, y: pathRect.minY, width: fadeWidth, height: pathRect.height),
color: theme.headerBackground,
fadesToRight: true,
context: context
)
}
}
private func drawHorizontalFade(
rect: CGRect,
color: UIColor,
fadesToRight: Bool,
context: CGContext
) {
guard rect.width > 0,
let gradient = CGGradient(
colorsSpace: CGColorSpaceCreateDeviceRGB(),
colors: [
color.withAlphaComponent(fadesToRight ? 0 : 1).cgColor,
color.withAlphaComponent(fadesToRight ? 1 : 0).cgColor,
] as CFArray,
locations: [0, 1]
) else {
return
}
context.saveGState()
context.clip(to: rect)
context.drawLinearGradient(
gradient,
start: CGPoint(x: rect.minX, y: rect.midY),
end: CGPoint(x: rect.maxX, y: rect.midY),
options: []
)
context.restoreGState()
}
private func textWidth(_ text: String, font: UIFont) -> CGFloat {
let attributes: [NSAttributedString.Key: Any] = [.font: font, .ligature: 0]
return ceil((text as NSString).size(withAttributes: attributes).width)
}
private func monospaceCharacterWidth(font: UIFont) -> CGFloat {
let sampleLength = 64
let sample = String(repeating: "M", count: sampleLength)
let attributes: [NSAttributedString.Key: Any] = [.font: font, .ligature: 0]
return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength)
}
private func tokenAttributedString(
rowId: String,
tokens: [ReviewDiffNativeToken],
fallbackColor: UIColor,
font: UIFont
) -> NSAttributedString {
if let cached = tokenAttributedStringsByRowId[rowId] {
return cached
}
let attributedString = NSMutableAttributedString(string: "")
for token in tokens where !token.content.isEmpty {
attributedString.append(
NSAttributedString(
string: token.content,
attributes: [
.font: font,
.foregroundColor: tokenColor(for: token.color, fallbackColor: fallbackColor),
.ligature: 0,
]
)
)
}
tokenAttributedStringsByRowId[rowId] = attributedString
return attributedString
}
private func tokenColor(for hex: String?, fallbackColor: UIColor) -> UIColor {
guard let hex, !hex.isEmpty else {
return fallbackColor
}
if let color = tokenColorsByHex[hex] {
return color
}
guard let color = UIColor(reviewDiffHex: hex) else {
return fallbackColor
}
tokenColorsByHex[hex] = color
return color
}
private func drawRightAlignedText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .right
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color,
.paragraphStyle: paragraphStyle,
]
(text as NSString).draw(in: rect, withAttributes: attributes)
}
}
private extension UIColor {
convenience init?(reviewDiffHex hex: String?) {
guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}
if value.hasPrefix("#") {
value.removeFirst()
}
guard value.count == 6 || value.count == 8 else {
return nil
}
var rawValue: UInt64 = 0
guard Scanner(string: value).scanHexInt64(&rawValue) else {
return nil
}
let red: CGFloat
let green: CGFloat
let blue: CGFloat
let alpha: CGFloat
if value.count == 8 {
red = CGFloat((rawValue >> 24) & 0xff) / 255
green = CGFloat((rawValue >> 16) & 0xff) / 255
blue = CGFloat((rawValue >> 8) & 0xff) / 255
alpha = CGFloat(rawValue & 0xff) / 255
} else {
red = CGFloat((rawValue >> 16) & 0xff) / 255
green = CGFloat((rawValue >> 8) & 0xff) / 255
blue = CGFloat(rawValue & 0xff) / 255
alpha = 1
}
self.init(red: red, green: green, blue: blue, alpha: alpha)
}
}