1import ExpoModulesCore
2import UIKit
3
4private struct ReviewDiffNativeRow: Decodable, Sendable {
5 let kind: String
6 let id: String
7 let fileId: String?
8 let filePath: String?
9 let previousPath: String?
10 let changeType: String?
11 let additions: Int?
12 let deletions: Int?
13 let text: String?
14 let content: String?
15 let change: String?
16 let oldLineNumber: Int?
17 let newLineNumber: Int?
18 let wordDiffRanges: [ReviewDiffNativeWordDiffRange]?
19 let commentText: String?
20 let commentRangeLabel: String?
21 let commentSectionTitle: String?
22}
23
24private struct ReviewDiffNativeWordDiffRange: Decodable, Sendable {
25 let start: Int
26 let end: Int
27}
28
29private struct ReviewDiffNativeToken: Decodable, Sendable {
30 let content: String
31 let color: String?
32 let fontStyle: Int?
33}
34
35private struct ReviewDiffNativeTokenPatch: Decodable, Sendable {
36 let resetKey: String?
37 let chunkIndex: Int?
38 let tokensByRowId: [String: [ReviewDiffNativeToken]]?
39}
40
41private struct ReviewDiffNativeThemePayload: Decodable {
42 let background: String?
43 let text: String?
44 let mutedText: String?
45 let headerBackground: String?
46 let border: String?
47 let hunkBackground: String?
48 let hunkText: String?
49 let addBackground: String?
50 let deleteBackground: String?
51 let addBar: String?
52 let deleteBar: String?
53 let addText: String?
54 let deleteText: String?
55}
56
57private struct ReviewDiffNativeTheme {
58 let background: UIColor
59 let text: UIColor
60 let mutedText: UIColor
61 let headerBackground: UIColor
62 let border: UIColor
63 let hunkBackground: UIColor
64 let hunkText: UIColor
65 let addBackground: UIColor
66 let deleteBackground: UIColor
67 let addBar: UIColor
68 let deleteBar: UIColor
69 let addText: UIColor
70 let deleteText: UIColor
71
72 static func resolve(_ scheme: String) -> ReviewDiffNativeTheme {
73 resolve(scheme, payload: nil)
74 }
75
76 static func resolve(
77 _ scheme: String,
78 payload: ReviewDiffNativeThemePayload?
79 ) -> ReviewDiffNativeTheme {
80 let fallback = fallback(scheme)
81 guard let payload else {
82 return fallback
83 }
84
85 return ReviewDiffNativeTheme(
86 background: UIColor(reviewDiffHex: payload.background) ?? fallback.background,
87 text: UIColor(reviewDiffHex: payload.text) ?? fallback.text,
88 mutedText: UIColor(reviewDiffHex: payload.mutedText) ?? fallback.mutedText,
89 headerBackground: UIColor(reviewDiffHex: payload.headerBackground) ?? fallback.headerBackground,
90 border: UIColor(reviewDiffHex: payload.border) ?? fallback.border,
91 hunkBackground: UIColor(reviewDiffHex: payload.hunkBackground) ?? fallback.hunkBackground,
92 hunkText: UIColor(reviewDiffHex: payload.hunkText) ?? fallback.hunkText,
93 addBackground: UIColor(reviewDiffHex: payload.addBackground) ?? fallback.addBackground,
94 deleteBackground: UIColor(reviewDiffHex: payload.deleteBackground) ?? fallback.deleteBackground,
95 addBar: UIColor(reviewDiffHex: payload.addBar) ?? fallback.addBar,
96 deleteBar: UIColor(reviewDiffHex: payload.deleteBar) ?? fallback.deleteBar,
97 addText: UIColor(reviewDiffHex: payload.addText) ?? fallback.addText,
98 deleteText: UIColor(reviewDiffHex: payload.deleteText) ?? fallback.deleteText
99 )
100 }
101
102 private static func fallback(_ scheme: String) -> ReviewDiffNativeTheme {
103 if scheme == "dark" {
104 return ReviewDiffNativeTheme(
105 background: UIColor(red: 0.07, green: 0.07, blue: 0.07, alpha: 1),
106 text: UIColor(red: 0.90, green: 0.90, blue: 0.90, alpha: 1),
107 mutedText: UIColor(red: 0.52, green: 0.52, blue: 0.52, alpha: 1),
108 headerBackground: UIColor(red: 0.10, green: 0.10, blue: 0.10, alpha: 1),
109 border: UIColor(red: 0.16, green: 0.16, blue: 0.16, alpha: 1),
110 hunkBackground: UIColor(red: 0.03, green: 0.14, blue: 0.18, alpha: 1),
111 hunkText: UIColor(red: 0.41, green: 0.82, blue: 1.00, alpha: 1),
112 addBackground: UIColor(red: 0.02, green: 0.16, blue: 0.10, alpha: 1),
113 deleteBackground: UIColor(red: 0.18, green: 0.05, blue: 0.08, alpha: 1),
114 addBar: UIColor(red: 0.02, green: 0.82, blue: 0.54, alpha: 1),
115 deleteBar: UIColor(red: 1.00, green: 0.36, blue: 0.50, alpha: 1),
116 addText: UIColor(red: 0.10, green: 0.84, blue: 0.56, alpha: 1),
117 deleteText: UIColor(red: 1.00, green: 0.38, blue: 0.52, alpha: 1)
118 )
119 }
120
121 return ReviewDiffNativeTheme(
122 background: UIColor.white,
123 text: UIColor(red: 0.16, green: 0.16, blue: 0.16, alpha: 1),
124 mutedText: UIColor(red: 0.47, green: 0.47, blue: 0.47, alpha: 1),
125 headerBackground: UIColor.white,
126 border: UIColor(red: 0.88, green: 0.88, blue: 0.90, alpha: 1),
127 hunkBackground: UIColor(red: 0.83, green: 0.92, blue: 0.98, alpha: 1),
128 hunkText: UIColor(red: 0.00, green: 0.45, blue: 0.74, alpha: 1),
129 addBackground: UIColor(red: 0.85, green: 0.94, blue: 0.92, alpha: 1),
130 deleteBackground: UIColor(red: 0.96, green: 0.85, blue: 0.90, alpha: 1),
131 addBar: UIColor(red: 0.02, green: 0.80, blue: 0.52, alpha: 1),
132 deleteBar: UIColor(red: 1.00, green: 0.34, blue: 0.48, alpha: 1),
133 addText: UIColor(red: 0.00, green: 0.56, blue: 0.34, alpha: 1),
134 deleteText: UIColor(red: 0.90, green: 0.10, blue: 0.24, alpha: 1)
135 )
136 }
137}
138
139private struct ReviewDiffNativeStylePayload: Decodable {
140 let wordWrap: Bool?
141 let rowHeight: Double?
142 let contentWidth: Double?
143 let changeBarWidth: Double?
144 let gutterWidth: Double?
145 let codePadding: Double?
146 let textVerticalInset: Double?
147 let fileHeaderHeight: Double?
148 let fileHeaderHorizontalMargin: Double?
149 let fileHeaderVerticalMargin: Double?
150 let fileHeaderCornerRadius: Double?
151 let fileHeaderHorizontalPadding: Double?
152 let fileHeaderPathRightPadding: Double?
153 let fileHeaderCountColumnWidth: Double?
154 let fileHeaderCountGap: Double?
155 let codeFontSize: Double?
156 let codeFontWeight: String?
157 let lineNumberFontSize: Double?
158 let lineNumberFontWeight: String?
159 let hunkFontSize: Double?
160 let hunkFontWeight: String?
161 let fileHeaderFontSize: Double?
162 let fileHeaderFontWeight: String?
163 let fileHeaderMetaFontSize: Double?
164 let fileHeaderMetaFontWeight: String?
165 let fileHeaderSubtextFontSize: Double?
166 let fileHeaderSubtextFontWeight: String?
167 let fileHeaderStatusFontSize: Double?
168 let fileHeaderStatusFontWeight: String?
169 let emptyStateFontSize: Double?
170 let emptyStateFontWeight: String?
171}
172
173private struct ReviewDiffNativeStyle {
174 let wordWrap: Bool
175 let rowHeight: CGFloat
176 let contentWidth: CGFloat
177 let changeBarWidth: CGFloat
178 let gutterWidth: CGFloat
179 let codePadding: CGFloat
180 let textVerticalInset: CGFloat
181 let fileHeaderHeight: CGFloat
182 let fileHeaderHorizontalMargin: CGFloat
183 let fileHeaderVerticalMargin: CGFloat
184 let fileHeaderCornerRadius: CGFloat
185 let fileHeaderHorizontalPadding: CGFloat
186 let fileHeaderPathRightPadding: CGFloat
187 let fileHeaderCountColumnWidth: CGFloat
188 let fileHeaderCountGap: CGFloat
189 let codeFontSize: CGFloat
190 let codeFontWeight: UIFont.Weight
191 let lineNumberFontSize: CGFloat
192 let lineNumberFontWeight: UIFont.Weight
193 let hunkFontSize: CGFloat
194 let hunkFontWeight: UIFont.Weight
195 let fileHeaderFontSize: CGFloat
196 let fileHeaderFontWeight: UIFont.Weight
197 let fileHeaderMetaFontSize: CGFloat
198 let fileHeaderMetaFontWeight: UIFont.Weight
199 let fileHeaderSubtextFontSize: CGFloat
200 let fileHeaderSubtextFontWeight: UIFont.Weight
201 let fileHeaderStatusFontSize: CGFloat
202 let fileHeaderStatusFontWeight: UIFont.Weight
203 let emptyStateFontSize: CGFloat
204 let emptyStateFontWeight: UIFont.Weight
205
206 static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle {
207 ReviewDiffNativeStyle(
208 wordWrap: payload?.wordWrap ?? false,
209 rowHeight: metric(payload?.rowHeight, fallback: 24),
210 contentWidth: metric(payload?.contentWidth, fallback: 2800),
211 changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4),
212 gutterWidth: metric(payload?.gutterWidth, fallback: 50),
213 codePadding: metric(payload?.codePadding, fallback: 8),
214 textVerticalInset: metric(payload?.textVerticalInset, fallback: 3),
215 fileHeaderHeight: metric(payload?.fileHeaderHeight, fallback: 54),
216 fileHeaderHorizontalMargin: metric(payload?.fileHeaderHorizontalMargin, fallback: 8),
217 fileHeaderVerticalMargin: metric(payload?.fileHeaderVerticalMargin, fallback: 6),
218 fileHeaderCornerRadius: metric(payload?.fileHeaderCornerRadius, fallback: 10),
219 fileHeaderHorizontalPadding: metric(payload?.fileHeaderHorizontalPadding, fallback: 12),
220 fileHeaderPathRightPadding: metric(payload?.fileHeaderPathRightPadding, fallback: 128),
221 fileHeaderCountColumnWidth: metric(payload?.fileHeaderCountColumnWidth, fallback: 42),
222 fileHeaderCountGap: metric(payload?.fileHeaderCountGap, fallback: 6),
223 codeFontSize: metric(payload?.codeFontSize, fallback: 12),
224 codeFontWeight: fontWeight(payload?.codeFontWeight, fallback: .bold),
225 lineNumberFontSize: metric(payload?.lineNumberFontSize, fallback: 11),
226 lineNumberFontWeight: fontWeight(payload?.lineNumberFontWeight, fallback: .bold),
227 hunkFontSize: metric(payload?.hunkFontSize, fallback: 12),
228 hunkFontWeight: fontWeight(payload?.hunkFontWeight, fallback: .bold),
229 fileHeaderFontSize: metric(payload?.fileHeaderFontSize, fallback: 13),
230 fileHeaderFontWeight: fontWeight(payload?.fileHeaderFontWeight, fallback: .bold),
231 fileHeaderMetaFontSize: metric(payload?.fileHeaderMetaFontSize, fallback: 12),
232 fileHeaderMetaFontWeight: fontWeight(payload?.fileHeaderMetaFontWeight, fallback: .bold),
233 fileHeaderSubtextFontSize: metric(payload?.fileHeaderSubtextFontSize, fallback: 11),
234 fileHeaderSubtextFontWeight: fontWeight(payload?.fileHeaderSubtextFontWeight, fallback: .medium),
235 fileHeaderStatusFontSize: metric(payload?.fileHeaderStatusFontSize, fallback: 10),
236 fileHeaderStatusFontWeight: fontWeight(payload?.fileHeaderStatusFontWeight, fallback: .semibold),
237 emptyStateFontSize: metric(payload?.emptyStateFontSize, fallback: 13),
238 emptyStateFontWeight: fontWeight(payload?.emptyStateFontWeight, fallback: .semibold)
239 )
240 }
241
242 private static func metric(_ value: Double?, fallback: CGFloat) -> CGFloat {
243 guard let value, value.isFinite, value > 0 else {
244 return fallback
245 }
246 return CGFloat(value)
247 }
248
249 private static func nonNegativeMetric(_ value: Double?, fallback: CGFloat) -> CGFloat {
250 guard let value, value.isFinite, value >= 0 else {
251 return fallback
252 }
253 return CGFloat(value)
254 }
255
256 private static func fontWeight(_ value: String?, fallback: UIFont.Weight) -> UIFont.Weight {
257 switch value?.lowercased() {
258 case "ultralight", "ultra-light":
259 return .ultraLight
260 case "thin":
261 return .thin
262 case "light":
263 return .light
264 case "regular":
265 return .regular
266 case "medium":
267 return .medium
268 case "semibold", "semi-bold":
269 return .semibold
270 case "bold":
271 return .bold
272 case "heavy":
273 return .heavy
274 case "black":
275 return .black
276 default:
277 return fallback
278 }
279 }
280
281 func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle {
282 ReviewDiffNativeStyle(
283 wordWrap: wordWrap,
284 rowHeight: rowHeight ?? self.rowHeight,
285 contentWidth: contentWidth ?? self.contentWidth,
286 changeBarWidth: changeBarWidth,
287 gutterWidth: gutterWidth,
288 codePadding: codePadding,
289 textVerticalInset: textVerticalInset,
290 fileHeaderHeight: fileHeaderHeight,
291 fileHeaderHorizontalMargin: fileHeaderHorizontalMargin,
292 fileHeaderVerticalMargin: fileHeaderVerticalMargin,
293 fileHeaderCornerRadius: fileHeaderCornerRadius,
294 fileHeaderHorizontalPadding: fileHeaderHorizontalPadding,
295 fileHeaderPathRightPadding: fileHeaderPathRightPadding,
296 fileHeaderCountColumnWidth: fileHeaderCountColumnWidth,
297 fileHeaderCountGap: fileHeaderCountGap,
298 codeFontSize: codeFontSize,
299 codeFontWeight: codeFontWeight,
300 lineNumberFontSize: lineNumberFontSize,
301 lineNumberFontWeight: lineNumberFontWeight,
302 hunkFontSize: hunkFontSize,
303 hunkFontWeight: hunkFontWeight,
304 fileHeaderFontSize: fileHeaderFontSize,
305 fileHeaderFontWeight: fileHeaderFontWeight,
306 fileHeaderMetaFontSize: fileHeaderMetaFontSize,
307 fileHeaderMetaFontWeight: fileHeaderMetaFontWeight,
308 fileHeaderSubtextFontSize: fileHeaderSubtextFontSize,
309 fileHeaderSubtextFontWeight: fileHeaderSubtextFontWeight,
310 fileHeaderStatusFontSize: fileHeaderStatusFontSize,
311 fileHeaderStatusFontWeight: fileHeaderStatusFontWeight,
312 emptyStateFontSize: emptyStateFontSize,
313 emptyStateFontWeight: emptyStateFontWeight
314 )
315 }
316}
317
318public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
319 private let payloadDecodeQueue = DispatchQueue(
320 label: "com.t3tools.review-diff.payload-decode",
321 qos: .userInitiated
322 )
323 private let scrollView = UIScrollView()
324 private let contentView = ReviewDiffContentView()
325 private var rows: [ReviewDiffNativeRow] = []
326 private var appearanceScheme: String = "light"
327 private var themePayload: ReviewDiffNativeThemePayload?
328 private var stylePayload: ReviewDiffNativeStylePayload?
329 private var rowHeightOverride: CGFloat?
330 private var contentWidthOverride: CGFloat?
331 private var lastMetricsDebugKey = ""
332 private var lastVisibleRangeDebugKey = ""
333 private var tokensResetKey = ""
334 private var contentResetKey = ""
335 private var initialRowIndex: Int?
336 private var hasAppliedInitialRowIndex = false
337 private var lastVisibleFileId: String?
338 private var pendingScrollFileId: String?
339 private var pendingScrollAnimated = false
340 private var isProgrammaticScrollActive = false
341 private var rowsDecodeGeneration = 0
342 private var tokensDecodeGeneration = 0
343
344 let onDebug = EventDispatcher()
345 let onVisibleFileChange = EventDispatcher()
346 let onToggleFile = EventDispatcher()
347 let onToggleViewedFile = EventDispatcher()
348 let onPressLine = EventDispatcher()
349 let onToggleComment = EventDispatcher()
350 let onPullToRefresh = EventDispatcher()
351
352 private let pullRefreshControl = UIRefreshControl()
353
354 public required init(appContext: AppContext? = nil) {
355 super.init(appContext: appContext)
356
357 clipsToBounds = true
358 backgroundColor = contentView.theme.background
359 scrollView.contentInsetAdjustmentBehavior = .never
360 scrollView.delegate = self
361 scrollView.clipsToBounds = true
362 scrollView.alwaysBounceVertical = true
363 scrollView.alwaysBounceHorizontal = false
364 scrollView.showsVerticalScrollIndicator = true
365 scrollView.showsHorizontalScrollIndicator = false
366 scrollView.backgroundColor = contentView.theme.background
367 pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged)
368 addSubview(scrollView)
369
370 contentView.backgroundColor = contentView.theme.background
371 contentView.onToggleFile = { [weak self] fileId in
372 self?.onToggleFile(["fileId": fileId])
373 }
374 contentView.onToggleViewedFile = { [weak self] fileId in
375 self?.onToggleViewedFile(["fileId": fileId])
376 }
377 contentView.onPressLine = { [weak self] payload in
378 self?.onPressLine(payload)
379 }
380 contentView.onToggleComment = { [weak self] commentId in
381 self?.onToggleComment(["commentId": commentId])
382 }
383 contentView.onDrawMetrics = { [weak self] metrics in
384 self?.emitDebug("draw-metrics", metrics)
385 }
386 scrollView.addSubview(contentView)
387 }
388
389 public override func layoutSubviews() {
390 super.layoutSubviews()
391 scrollView.frame = bounds
392 updateContentMetrics()
393 }
394
395 public func scrollViewDidScroll(_ scrollView: UIScrollView) {
396 if scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating {
397 contentView.isVerticalScrollActive = true
398 }
399 updateViewportFrame()
400 }
401
402 public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
403 // A direct gesture takes ownership from any interrupted programmatic jump.
404 // Resume visible-file events immediately so the inspector follows the finger.
405 isProgrammaticScrollActive = false
406 contentView.isVerticalScrollActive = true
407 }
408
409 public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
410 guard !decelerate else {
411 return
412 }
413
414 finishVerticalScroll()
415 }
416
417 public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
418 finishVerticalScroll()
419 }
420
421 public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
422 finishVerticalScroll()
423 }
424
425 func setRowsJson(_ rowsJson: String) {
426 rowsDecodeGeneration += 1
427 let generation = rowsDecodeGeneration
428
429 payloadDecodeQueue.async { [weak self] in
430 guard let data = rowsJson.data(using: .utf8) else {
431 return
432 }
433
434 do {
435 let decodedRows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)
436 DispatchQueue.main.async { [weak self] in
437 guard let self, generation == self.rowsDecodeGeneration else {
438 return
439 }
440 self.contentView.prepareRows(decodedRows, on: self.payloadDecodeQueue, isCurrent: { [weak self] in
441 generation == self?.rowsDecodeGeneration
442 }, completion: { [weak self] in
443 guard let self, generation == self.rowsDecodeGeneration else { return }
444 self.rows = decodedRows
445 self.contentView.rows = decodedRows
446 self.hasAppliedInitialRowIndex = false
447 self.lastVisibleFileId = nil
448 self.emitDebug("rows-decoded", [
449 "rows": decodedRows.count,
450 "firstKind": decodedRows.first?.kind ?? "none",
451 ])
452 self.updateContentMetrics()
453 self.applyPendingScrollIfNeeded()
454 })
455 }
456 } catch {
457 let message = error.localizedDescription
458 DispatchQueue.main.async { [weak self] in
459 guard let self, generation == self.rowsDecodeGeneration else {
460 return
461 }
462 self.rows = []
463 self.contentView.rows = []
464 self.hasAppliedInitialRowIndex = false
465 self.lastVisibleFileId = nil
466 self.pendingScrollFileId = nil
467 self.updateContentMetrics()
468 self.emitDebug("rows-decode-failed", ["error": message])
469 }
470 }
471 }
472 }
473
474 func setTokensJson(_ tokensJson: String) {
475 tokensDecodeGeneration += 1
476 let generation = tokensDecodeGeneration
477
478 payloadDecodeQueue.async { [weak self] in
479 guard let data = tokensJson.data(using: .utf8) else {
480 return
481 }
482
483 do {
484 let decodedTokens = try JSONDecoder().decode(
485 [String: [ReviewDiffNativeToken]].self,
486 from: data
487 )
488 DispatchQueue.main.async { [weak self] in
489 guard let self, generation == self.tokensDecodeGeneration else {
490 return
491 }
492 self.contentView.tokensByRowId = decodedTokens
493 }
494 } catch {
495 let message = error.localizedDescription
496 DispatchQueue.main.async { [weak self] in
497 guard let self, generation == self.tokensDecodeGeneration else {
498 return
499 }
500 self.contentView.tokensByRowId = [:]
501 self.emitDebug("tokens-decode-failed", ["error": message])
502 }
503 }
504 }
505 }
506
507 func setTokensPatchJson(_ tokensPatchJson: String) {
508 let contentResetKey = self.contentResetKey
509
510 payloadDecodeQueue.async { [weak self] in
511 guard let data = tokensPatchJson.data(using: .utf8) else {
512 return
513 }
514
515 do {
516 let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data)
517 DispatchQueue.main.async { [weak self] in
518 guard let self else {
519 return
520 }
521 guard contentResetKey == self.contentResetKey else {
522 return
523 }
524 // A highlighter request from the previous file can finish after the view has
525 // already reset. Never let that stale patch roll the native token state back.
526 if let resetKey = patch.resetKey, resetKey != self.tokensResetKey {
527 return
528 }
529
530 let tokensByRowId = patch.tokensByRowId ?? [:]
531 if tokensByRowId.isEmpty {
532 return
533 }
534
535 self.contentView.mergeTokensByRowId(tokensByRowId)
536 if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) {
537 self.emitDebug("tokens-patch-decoded", [
538 "chunkIndex": chunkIndex,
539 "rows": tokensByRowId.count,
540 "totalRows": self.contentView.tokensByRowId.count,
541 ])
542 }
543 }
544 } catch {
545 let message = error.localizedDescription
546 DispatchQueue.main.async { [weak self] in
547 self?.emitDebug("tokens-patch-decode-failed", ["error": message])
548 }
549 }
550 }
551 }
552
553 func setTokensResetKey(_ tokensResetKey: String) {
554 guard tokensResetKey != self.tokensResetKey else {
555 return
556 }
557
558 self.tokensResetKey = tokensResetKey
559 contentView.tokensByRowId = [:]
560 emitDebug("tokens-reset", [
561 "resetKey": tokensResetKey,
562 ])
563 }
564
565 // Code surfaces fill the screen so UIKit can sample content below the glass bar.
566 // Keep the same visible row when header or safe-area reservations change.
567 func setContentInsetTop(_ inset: CGFloat) {
568 scrollView.automaticallyAdjustsScrollIndicatorInsets = false
569 let inset = max(0, inset)
570 let delta = inset - scrollView.contentInset.top
571 guard delta != 0 else { return }
572 scrollView.contentInset.top = inset
573 contentView.topContentInset = inset
574 scrollView.verticalScrollIndicatorInsets.top = inset
575 scrollView.contentOffset.y -= delta
576 updateViewportFrame()
577 }
578
579 func setContentInsetBottom(_ inset: CGFloat) {
580 scrollView.contentInset.bottom = max(0, inset)
581 scrollView.verticalScrollIndicatorInsets.bottom = max(0, inset)
582 }
583
584 func setContentResetKey(_ contentResetKey: String) {
585 guard contentResetKey != self.contentResetKey else {
586 return
587 }
588
589 self.contentResetKey = contentResetKey
590 rowsDecodeGeneration += 1
591 tokensDecodeGeneration += 1
592 contentView.tokensByRowId = [:]
593 rows = []
594 contentView.rows = []
595 hasAppliedInitialRowIndex = false
596 pendingScrollFileId = nil
597 isProgrammaticScrollActive = false
598 scrollView.setContentOffset(CGPoint(x: 0, y: -scrollView.contentInset.top), animated: false)
599 updateContentMetrics()
600 updateViewportFrame()
601 lastVisibleFileId = nil
602 applyInitialRowIndexIfNeeded()
603 }
604
605 func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) {
606 let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson)
607 let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds)
608 let scrollAnchor: ReviewDiffScrollAnchor?
609 if changedFileIds.count == 1, let changedFileId = changedFileIds.first {
610 scrollAnchor = contentView.scrollAnchor(forFileId: changedFileId)
611 } else {
612 scrollAnchor = nil
613 }
614
615 contentView.collapsedFileIds = nextCollapsedFileIds
616 updateContentMetrics()
617
618 if let scrollAnchor,
619 let headerOffset = contentView.fileHeaderOffset(forFileId: scrollAnchor.fileId) {
620 let targetOffset = headerOffset - scrollAnchor.screenY
621 let minOffset = -scrollView.contentInset.top
622 let maxOffset = max(
623 scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom,
624 minOffset
625 )
626 let clampedOffset = min(max(targetOffset, minOffset), maxOffset)
627 scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: false)
628 updateViewportFrame()
629 }
630 }
631
632 func setViewedFileIdsJson(_ viewedFileIdsJson: String) {
633 contentView.viewedFileIds = decodeFileIdSet(viewedFileIdsJson)
634 }
635
636 func setSelectedRowIdsJson(_ selectedRowIdsJson: String) {
637 contentView.selectedRowIds = decodeFileIdSet(selectedRowIdsJson)
638 }
639
640 func setCollapsedCommentIdsJson(_ collapsedCommentIdsJson: String) {
641 contentView.collapsedCommentIds = decodeFileIdSet(collapsedCommentIdsJson)
642 updateContentMetrics()
643 }
644
645 private func decodeFileIdSet(_ json: String) -> Set<String> {
646 guard let data = json.data(using: .utf8) else {
647 return []
648 }
649
650 do {
651 return Set(try JSONDecoder().decode([String].self, from: data))
652 } catch {
653 emitDebug("file-id-set-decode-failed", [
654 "error": error.localizedDescription,
655 ])
656 return []
657 }
658 }
659
660 func setAppearanceScheme(_ appearanceScheme: String) {
661 self.appearanceScheme = appearanceScheme
662 applyTheme()
663 }
664
665 func setThemeJson(_ themeJson: String) {
666 guard let data = themeJson.data(using: .utf8) else {
667 themePayload = nil
668 applyTheme()
669 return
670 }
671
672 do {
673 themePayload = try JSONDecoder().decode(ReviewDiffNativeThemePayload.self, from: data)
674 } catch {
675 themePayload = nil
676 emitDebug("theme-decode-failed", [
677 "error": error.localizedDescription,
678 ])
679 }
680
681 applyTheme()
682 }
683
684 private func updateContentMetrics() {
685 let style = contentView.style
686 contentView.viewportWidth = bounds.width
687 let height = max(bounds.height, contentView.contentHeight)
688 let width = bounds.width
689 scrollView.contentSize = CGSize(width: bounds.width, height: height)
690 contentView.frame = CGRect(
691 x: 0,
692 y: scrollView.contentOffset.y,
693 width: max(width, 1),
694 height: max(bounds.height, 1)
695 )
696 contentView.verticalOffset = scrollView.contentOffset.y
697 contentView.invalidateVisibleViewport()
698 contentView.setNeedsDisplay()
699 applyInitialRowIndexIfNeeded()
700 applyPendingScrollIfNeeded()
701 emitVisibleFileIfNeeded()
702
703 let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(contentView.contentHeight))"
704 if debugKey != lastMetricsDebugKey {
705 lastMetricsDebugKey = debugKey
706 emitDebug("metrics", [
707 "rows": rows.count,
708 "boundsWidth": Double(bounds.width),
709 "boundsHeight": Double(bounds.height),
710 "contentHeight": Double(contentView.contentHeight),
711 "contentWidth": Double(style.contentWidth),
712 "fileHeaderHeight": Double(style.fileHeaderHeight),
713 "rowHeight": Double(style.rowHeight),
714 ])
715 }
716 }
717
718 private func emitDebug(_ message: String, _ details: [String: Any]) {
719 var payload = details
720 payload["message"] = message
721 onDebug(payload)
722 }
723
724 private func finishVerticalScroll() {
725 isProgrammaticScrollActive = false
726 contentView.isVerticalScrollActive = false
727 updateViewportFrame()
728 emitVisibleRange(reason: "scroll-end")
729 }
730
731 private func emitVisibleFileIfNeeded() {
732 // Keep the explicit destination selected while UIKit animates through the files
733 // between the old and new offsets. Emitting every intermediate header forces a
734 // React render per crossing and makes the navigator visibly flash.
735 guard !isProgrammaticScrollActive else {
736 return
737 }
738
739 // The top of the combined diff is the explicit "All files" destination.
740 // Treat it as a first-class selection instead of immediately resolving the
741 // first file header and undoing the navigator's optimistic selection.
742 let visibleTop = scrollView.contentOffset.y + scrollView.contentInset.top
743 if visibleTop <= 0.5 {
744 guard lastVisibleFileId != nil else {
745 return
746 }
747 lastVisibleFileId = nil
748 onVisibleFileChange(["fileId": NSNull()])
749 return
750 }
751
752 guard let fileId = contentView.visibleFileId(atVerticalOffset: visibleTop),
753 fileId != lastVisibleFileId else {
754 return
755 }
756
757 lastVisibleFileId = fileId
758 onVisibleFileChange(["fileId": fileId])
759 }
760
761 private func emitVisibleRange(reason: String) {
762 guard let range = contentView.currentVisibleRowRange() else {
763 return
764 }
765
766 let debugKey = "\(range.firstRowIndex):\(range.lastRowIndex):\(Int(scrollView.bounds.height))"
767 guard debugKey != lastVisibleRangeDebugKey else {
768 return
769 }
770
771 lastVisibleRangeDebugKey = debugKey
772 emitDebug("visible-range", [
773 "reason": reason,
774 "firstRowIndex": range.firstRowIndex,
775 "lastRowIndex": range.lastRowIndex,
776 "totalRows": rows.count,
777 ])
778 }
779
780 private func applyTheme() {
781 contentView.theme = ReviewDiffNativeTheme.resolve(appearanceScheme, payload: themePayload)
782 backgroundColor = contentView.theme.background
783 scrollView.backgroundColor = contentView.theme.background
784 contentView.backgroundColor = contentView.theme.background
785 contentView.invalidateVisibleViewport()
786 }
787
788 func setStyleJson(_ styleJson: String) {
789 guard let data = styleJson.data(using: .utf8) else {
790 stylePayload = nil
791 applyStyle()
792 return
793 }
794
795 do {
796 stylePayload = try JSONDecoder().decode(ReviewDiffNativeStylePayload.self, from: data)
797 } catch {
798 stylePayload = nil
799 emitDebug("style-decode-failed", [
800 "error": error.localizedDescription,
801 ])
802 }
803
804 applyStyle()
805 }
806
807 func setRowHeight(_ rowHeight: CGFloat) {
808 rowHeightOverride = rowHeight.isFinite && rowHeight > 0 ? rowHeight : nil
809 applyStyle()
810 }
811
812 func setContentWidth(_ contentWidth: CGFloat) {
813 contentWidthOverride = contentWidth.isFinite && contentWidth > 0 ? contentWidth : nil
814 applyStyle()
815 }
816
817 func setInitialRowIndex(_ initialRowIndex: Double) {
818 let nextIndex: Int? = initialRowIndex.isFinite && initialRowIndex >= 0
819 ? Int(initialRowIndex.rounded(.down))
820 : nil
821 guard nextIndex != self.initialRowIndex else {
822 return
823 }
824
825 self.initialRowIndex = nextIndex
826 hasAppliedInitialRowIndex = false
827 applyInitialRowIndexIfNeeded()
828 }
829
830 func scrollToFile(_ fileId: String, animated: Bool) {
831 pendingScrollFileId = fileId
832 pendingScrollAnimated = animated
833 applyPendingScrollIfNeeded()
834 }
835
836 func scrollToTop(animated: Bool) {
837 pendingScrollFileId = nil
838 pendingScrollAnimated = false
839 setVerticalContentOffset(0, animated: animated)
840 }
841
842 func setScrollEnabled(_ enabled: Bool) {
843 scrollView.isScrollEnabled = enabled
844 }
845
846 func setBounces(_ bounces: Bool) {
847 scrollView.bounces = bounces
848 scrollView.alwaysBounceVertical = bounces
849 }
850
851 func setRefreshEnabled(_ enabled: Bool) {
852 if !enabled { pullRefreshControl.endRefreshing() }
853 scrollView.refreshControl = enabled ? pullRefreshControl : nil
854 }
855
856 @objc private func handlePullToRefresh() {
857 onPullToRefresh([:])
858 }
859
860 /// Driven from JS: set to false once the reload completes to dismiss the spinner.
861 func setRefreshing(_ refreshing: Bool) {
862 if refreshing {
863 if !pullRefreshControl.isRefreshing {
864 pullRefreshControl.beginRefreshing()
865 }
866 } else if pullRefreshControl.isRefreshing {
867 pullRefreshControl.endRefreshing()
868 }
869 }
870
871 private func applyStyle() {
872 contentView.style = ReviewDiffNativeStyle
873 .resolve(stylePayload)
874 .applyingOverrides(rowHeight: rowHeightOverride, contentWidth: contentWidthOverride)
875 updateContentMetrics()
876 }
877
878 private func updateViewportFrame() {
879 contentView.frame = CGRect(
880 x: 0,
881 y: scrollView.contentOffset.y,
882 width: max(bounds.width, 1),
883 height: max(bounds.height, 1)
884 )
885 contentView.verticalOffset = scrollView.contentOffset.y
886 contentView.invalidateVisibleViewport()
887 emitVisibleFileIfNeeded()
888 }
889
890 private func applyPendingScrollIfNeeded() {
891 guard let fileId = pendingScrollFileId,
892 bounds.height > 0 else {
893 return
894 }
895
896 guard let headerOffset = contentView.fileHeaderOffset(forFileId: fileId) else {
897 if !rows.isEmpty {
898 pendingScrollFileId = nil
899 }
900 return
901 }
902
903 let animated = pendingScrollAnimated
904 pendingScrollFileId = nil
905 pendingScrollAnimated = false
906 setVerticalContentOffset(headerOffset, animated: animated)
907 }
908
909 private func setVerticalContentOffset(_ targetOffset: CGFloat, animated: Bool) {
910 let topInset = scrollView.contentInset.top
911 let maxOffset = max(
912 scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom,
913 -topInset
914 )
915 let clampedOffset = min(max(targetOffset - topInset, -topInset), maxOffset)
916 let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5
917 isProgrammaticScrollActive = shouldAnimate
918 contentView.isVerticalScrollActive = shouldAnimate
919 scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: shouldAnimate)
920 if !shouldAnimate {
921 updateViewportFrame()
922 }
923 }
924
925 private func applyInitialRowIndexIfNeeded() {
926 guard !hasAppliedInitialRowIndex,
927 let initialRowIndex,
928 bounds.height > 0,
929 let rowFrame = contentView.frameForRow(at: initialRowIndex) else {
930 return
931 }
932
933 let visibleHeight = bounds.height - scrollView.contentInset.top - scrollView.contentInset.bottom
934 let targetScreenY = max(0, (visibleHeight - rowFrame.height) * 0.3)
935 hasAppliedInitialRowIndex = true
936 setVerticalContentOffset(rowFrame.minY - targetScreenY, animated: false)
937 }
938}
939
940private enum ReviewDiffHorizontalPanKind {
941 case code
942 case fileHeaderPath
943}
944
945private struct ReviewDiffFileHeaderPathLayout {
946 let displayPath: String
947 let rect: CGRect
948}
949
950private struct ReviewDiffFileHeaderInteractiveRects {
951 let chevron: CGRect
952 let icon: CGRect
953 let checkbox: CGRect
954}
955
956private struct ReviewDiffStickyFileHeaderIndex {
957 let position: Int
958 let rowIndex: Int
959}
960
961private struct ReviewDiffStickyFileHeaderTarget {
962 let rowIndex: Int
963 let row: ReviewDiffNativeRow
964 let rect: CGRect
965}
966
967private struct ReviewDiffScrollAnchor {
968 let fileId: String
969 let screenY: CGFloat
970}
971
972private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
973 var rows: [ReviewDiffNativeRow] = [] {
974 didSet {
975 stopHorizontalDeceleration()
976 horizontalOffsetsByFileId.removeAll()
977 headerPathOffsetsByFileId.removeAll()
978 activePanFileId = nil
979 activePanKind = nil
980 codeDecorationVersion += 1
981 tokenAttributedStringsByRowId.removeAll()
982 rebuildRowLayout()
983 setNeedsDisplayForVisibleBounds()
984 }
985 }
986 var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] {
987 didSet {
988 codeDecorationVersion += 1
989 tokenAttributedStringsByRowId.removeAll()
990 clampHorizontalOffsets()
991 setNeedsDisplayForVisibleBounds()
992 }
993 }
994
995 func mergeTokensByRowId(_ tokensPatch: [String: [ReviewDiffNativeToken]]) {
996 tokensPatch.forEach { rowId, tokens in
997 tokensByRowId[rowId] = tokens
998 tokenAttributedStringsByRowId.removeValue(forKey: rowId)
999 }
1000 clampHorizontalOffsets()
1001 setNeedsDisplayForVisibleBounds()
1002 }
1003
1004 var collapsedFileIds: Set<String> = [] {
1005 didSet {
1006 rebuildRowLayout()
1007 clampHorizontalOffsets()
1008 setNeedsDisplayForVisibleBounds()
1009 }
1010 }
1011 var viewedFileIds: Set<String> = [] {
1012 didSet {
1013 setNeedsDisplayForVisibleBounds()
1014 }
1015 }
1016 var selectedRowIds: Set<String> = [] {
1017 didSet {
1018 setNeedsDisplayForVisibleBounds()
1019 }
1020 }
1021 var collapsedCommentIds: Set<String> = [] {
1022 didSet {
1023 rebuildRowLayout()
1024 setNeedsDisplayForVisibleBounds()
1025 }
1026 }
1027 var style = ReviewDiffNativeStyle.resolve(nil) {
1028 didSet {
1029 codeDecorationVersion += 1
1030 tokenAttributedStringsByRowId.removeAll()
1031 rebuildRowLayout()
1032 clampHorizontalOffsets()
1033 setNeedsDisplayForVisibleBounds()
1034 }
1035 }
1036 var viewportWidth: CGFloat = 0 {
1037 didSet {
1038 // Wrapped rows take their height from the width, so a new width is a new layout.
1039 if style.wordWrap, viewportWidth != oldValue {
1040 rebuildRowLayout()
1041 }
1042 clampHorizontalOffsets()
1043 setNeedsDisplayForVisibleBounds()
1044 }
1045 }
1046 var verticalOffset: CGFloat = 0
1047 var topContentInset: CGFloat = 0
1048 var theme = ReviewDiffNativeTheme.resolve("light") {
1049 didSet {
1050 tokenColorsByHex.removeAll()
1051 codeDecorationVersion += 1
1052 tokenAttributedStringsByRowId.removeAll()
1053 setNeedsDisplayForVisibleBounds()
1054 }
1055 }
1056 private(set) var contentHeight: CGFloat = 0
1057
1058 private var rowOffsets: [CGFloat] = []
1059 private var fileHeaderRowIndices: [Int] = []
1060 private var contentWidthsByFileId: [String: CGFloat] = [:]
1061 private var tokenColorsByHex: [String: UIColor] = [:]
1062 private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:]
1063 private var codeCharacterWidth: CGFloat = 8
1064 /// Columns per visual line while word wrap is on; nil while code rows pan horizontally.
1065 private var codeWrapColumns: Int?
1066 /// Text geometry survives comment height changes; width, font, and content invalidate it.
1067 private var codeLayoutsByRowId: [String: ReviewDiffCodeLayout] = [:]
1068 private var codeLayoutWidth: CGFloat = 0
1069 private var codeLayoutFont: UIFont?
1070 private var codeDecorationVersion = 0
1071 private var panStartHorizontalOffset: CGFloat = 0
1072 private var activePanFileId: String?
1073 private var activePanKind: ReviewDiffHorizontalPanKind?
1074 private var horizontalOffsetsByFileId: [String: CGFloat] = [:]
1075 private var headerPathOffsetsByFileId: [String: CGFloat] = [:]
1076 private var decelerationDisplayLink: CADisplayLink?
1077 private var deceleratingFileId: String?
1078 private var horizontalVelocity: CGFloat = 0
1079 private var lastDecelerationTimestamp: CFTimeInterval = 0
1080 private var lastDrawMetricsTimestamp: CFTimeInterval = 0
1081 var isVerticalScrollActive = false
1082 var onToggleFile: ((String) -> Void)?
1083 var onToggleViewedFile: ((String) -> Void)?
1084 var onPressLine: (([String: Any]) -> Void)?
1085 var onToggleComment: ((String) -> Void)?
1086 var onDrawMetrics: (([String: Any]) -> Void)?
1087
1088 private var stickyWidth: CGFloat {
1089 style.changeBarWidth + style.gutterWidth
1090 }
1091
1092 private var codeStartX: CGFloat {
1093 stickyWidth + style.codePadding
1094 }
1095
1096 /// Height before word wrap. Laid-out rows use height(at:), which includes wrapped lines.
1097 private func height(for row: ReviewDiffNativeRow) -> CGFloat {
1098 if row.kind == "file" {
1099 return style.fileHeaderHeight
1100 }
1101 if collapsedFileIds.contains(resolvedFileId(for: row)) {
1102 return 0
1103 }
1104 if row.kind == "notice" {
1105 return max(style.rowHeight * 2, 44)
1106 }
1107 if row.kind == "comment" {
1108 return collapsedCommentIds.contains(row.id) ? 44 : 124
1109 }
1110 return style.rowHeight
1111 }
1112
1113 /// Laid-out height, including wrapped lines. Requires a layout built from the current rows.
1114 private func height(at index: Int) -> CGFloat {
1115 let nextOffset = index + 1 < rowOffsets.count ? rowOffsets[index + 1] : contentHeight
1116 return nextOffset - rowOffsets[index]
1117 }
1118
1119 private var codeWrapLineHeight: CGFloat {
1120 ceil(codeFont.lineHeight)
1121 }
1122
1123 func frameForRow(at index: Int) -> CGRect? {
1124 guard rows.indices.contains(index), rowOffsets.indices.contains(index) else {
1125 return nil
1126 }
1127
1128 return CGRect(
1129 x: 0,
1130 y: rowOffsets[index],
1131 width: max(viewportWidth, 1),
1132 height: height(at: index)
1133 )
1134 }
1135
1136 /// Shape new content on the existing decode worker before publishing rows to the UI.
1137 func prepareRows(
1138 _ rows: [ReviewDiffNativeRow],
1139 on queue: DispatchQueue,
1140 isCurrent: @escaping () -> Bool,
1141 completion: @escaping () -> Void
1142 ) {
1143 let font = codeFont
1144 let width = viewportWidth - codeStartX - style.codePadding
1145 let characterWidth = monospaceCharacterWidth(font: font)
1146 guard style.wordWrap, width >= characterWidth, characterWidth > 0 else {
1147 completion()
1148 return
1149 }
1150 let cached = codeLayoutWidth == width && codeLayoutFont == font ? codeLayoutsByRowId : [:]
1151 queue.async { [weak self] in
1152 var layouts: [String: ReviewDiffCodeLayout] = [:]
1153 for row in rows where row.kind == "line" {
1154 guard let text = row.content else { continue }
1155 if let previous = cached[row.id], previous.text == text {
1156 layouts[row.id] = previous
1157 } else {
1158 layouts[row.id] = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth)
1159 }
1160 }
1161 DispatchQueue.main.async { [weak self] in
1162 guard let self, isCurrent() else { return }
1163 if self.codeFont != font || self.viewportWidth - self.codeStartX - self.style.codePadding != width {
1164 self.prepareRows(rows, on: queue, isCurrent: isCurrent, completion: completion)
1165 return
1166 }
1167 self.codeLayoutWidth = width
1168 self.codeLayoutFont = font
1169 self.codeLayoutsByRowId = layouts
1170 completion()
1171 }
1172 }
1173 }
1174
1175 private func rebuildRowLayout() {
1176 var nextOffsets: [CGFloat] = []
1177 var nextFileHeaderRowIndices: [Int] = []
1178 nextOffsets.reserveCapacity(rows.count)
1179 var maxColumnCountsByFileId: [String: Int] = [:]
1180 var nextCodeLayouts: [String: ReviewDiffCodeLayout] = [:]
1181 var offset: CGFloat = 0
1182 let font = codeFont
1183 let characterWidth = monospaceCharacterWidth(font: font)
1184 let wrapAvailableWidth = viewportWidth - codeStartX - style.codePadding
1185 let wrapColumns = style.wordWrap && characterWidth > 0 && wrapAvailableWidth >= characterWidth
1186 ? Int(wrapAvailableWidth / characterWidth)
1187 : nil
1188 if codeLayoutWidth != wrapAvailableWidth || codeLayoutFont != font {
1189 codeLayoutsByRowId.removeAll()
1190 codeLayoutWidth = wrapAvailableWidth
1191 codeLayoutFont = font
1192 }
1193
1194 for (index, row) in rows.enumerated() {
1195 nextOffsets.append(offset)
1196 if row.kind == "file" {
1197 nextFileHeaderRowIndices.append(index)
1198 }
1199 var rowHeight = height(for: row)
1200
1201 let fileId = resolvedFileId(for: row)
1202 switch row.kind {
1203 case "line":
1204 // UTF-16 columns match the word diff ranges and the segments drawCodeLines draws.
1205 let columnCount = row.content?.utf16.count ?? 0
1206 maxColumnCountsByFileId[fileId] = max(maxColumnCountsByFileId[fileId] ?? 0, columnCount)
1207 if wrapColumns != nil, let content = row.content {
1208 let cached = codeLayoutsByRowId[row.id]
1209 let layout: ReviewDiffCodeLayout
1210 if let cached, cached.text == content {
1211 layout = cached
1212 } else {
1213 layout = ReviewDiffCodeLayout(
1214 text: content, font: font, width: wrapAvailableWidth, characterWidth: characterWidth
1215 )
1216 }
1217 nextCodeLayouts[row.id] = layout
1218 if rowHeight > 0 {
1219 rowHeight = max(rowHeight, layout.firstLineHeight) + layout.extraHeight
1220 }
1221 }
1222 case "hunk":
1223 maxColumnCountsByFileId[fileId] = max(
1224 maxColumnCountsByFileId[fileId] ?? 0,
1225 row.text?.utf16.count ?? 0
1226 )
1227 default:
1228 break
1229 }
1230 offset += rowHeight
1231 }
1232
1233 codeCharacterWidth = characterWidth
1234 codeWrapColumns = wrapColumns
1235 codeLayoutsByRowId = nextCodeLayouts
1236 contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in
1237 let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2
1238 return max(0, min(style.contentWidth, measuredWidth))
1239 }
1240 rowOffsets = nextOffsets
1241 fileHeaderRowIndices = nextFileHeaderRowIndices
1242 contentHeight = offset
1243 }
1244
1245 private var codeFont: UIFont {
1246 UIFont.monospacedSystemFont(ofSize: style.codeFontSize, weight: style.codeFontWeight)
1247 }
1248
1249 private var lineNumberFont: UIFont {
1250 UIFont.monospacedSystemFont(ofSize: style.lineNumberFontSize, weight: style.lineNumberFontWeight)
1251 }
1252
1253 private var hunkFont: UIFont {
1254 UIFont.monospacedSystemFont(ofSize: style.hunkFontSize, weight: style.hunkFontWeight)
1255 }
1256
1257 private var fileHeaderFont: UIFont {
1258 UIFont.systemFont(ofSize: style.fileHeaderFontSize, weight: style.fileHeaderFontWeight)
1259 }
1260
1261 private var fileHeaderMetaFont: UIFont {
1262 UIFont.systemFont(ofSize: style.fileHeaderMetaFontSize, weight: style.fileHeaderMetaFontWeight)
1263 }
1264
1265 private var fileHeaderSubtextFont: UIFont {
1266 UIFont.systemFont(ofSize: style.fileHeaderSubtextFontSize, weight: style.fileHeaderSubtextFontWeight)
1267 }
1268
1269 private var fileHeaderStatusFont: UIFont {
1270 UIFont.systemFont(ofSize: style.fileHeaderStatusFontSize, weight: style.fileHeaderStatusFontWeight)
1271 }
1272
1273 private var emptyStateFont: UIFont {
1274 UIFont.systemFont(ofSize: style.emptyStateFontSize, weight: style.emptyStateFontWeight)
1275 }
1276
1277 private lazy var horizontalPanGesture: UIPanGestureRecognizer = {
1278 let gesture = UIPanGestureRecognizer(target: self, action: #selector(handleHorizontalPan(_:)))
1279 gesture.delegate = self
1280 gesture.cancelsTouchesInView = false
1281 return gesture
1282 }()
1283
1284 private lazy var tapGesture: UITapGestureRecognizer = {
1285 let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
1286 gesture.delegate = self
1287 gesture.cancelsTouchesInView = false
1288 return gesture
1289 }()
1290
1291 private lazy var longPressGesture: UILongPressGestureRecognizer = {
1292 let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
1293 gesture.delegate = self
1294 gesture.cancelsTouchesInView = false
1295 gesture.minimumPressDuration = 0.28
1296 return gesture
1297 }()
1298
1299 override init(frame: CGRect) {
1300 super.init(frame: frame)
1301 isOpaque = true
1302 contentMode = .redraw
1303 addGestureRecognizer(horizontalPanGesture)
1304 addGestureRecognizer(tapGesture)
1305 addGestureRecognizer(longPressGesture)
1306 tapGesture.require(toFail: longPressGesture)
1307 }
1308
1309 func invalidateVisibleViewport() {
1310 setNeedsDisplayForVisibleBounds()
1311 }
1312
1313 private func setNeedsDisplayForVisibleBounds() {
1314 setNeedsDisplay()
1315 }
1316
1317 required init?(coder: NSCoder) {
1318 super.init(coder: coder)
1319 isOpaque = true
1320 contentMode = .redraw
1321 addGestureRecognizer(horizontalPanGesture)
1322 addGestureRecognizer(tapGesture)
1323 addGestureRecognizer(longPressGesture)
1324 tapGesture.require(toFail: longPressGesture)
1325 }
1326
1327 override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
1328 guard gestureRecognizer === horizontalPanGesture else {
1329 return true
1330 }
1331
1332 let velocity = horizontalPanGesture.velocity(in: self)
1333 guard abs(velocity.x) > abs(velocity.y) * 1.25 else {
1334 return false
1335 }
1336
1337 guard let panTarget = horizontalPanTarget(at: horizontalPanGesture.location(in: self)) else {
1338 return false
1339 }
1340
1341 let currentOffset = horizontalOffset(for: panTarget.fileId, kind: panTarget.kind)
1342 if velocity.x > 0 && currentOffset <= 0.5 {
1343 return false
1344 }
1345
1346 return maxHorizontalOffset(for: panTarget) > 0
1347 }
1348
1349 @objc private func handleTap(_ gesture: UITapGestureRecognizer) {
1350 guard gesture.state == .ended else {
1351 return
1352 }
1353
1354 let point = gesture.location(in: self)
1355 if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) {
1356 handleFileHeaderTap(row: stickyHeader.row, rect: stickyHeader.rect, point: point)
1357 return
1358 }
1359
1360 guard let rowIndex = rowIndex(at: verticalOffset + point.y),
1361 rows.indices.contains(rowIndex) else {
1362 return
1363 }
1364
1365 let row = rows[rowIndex]
1366 if row.kind == "comment" {
1367 onToggleComment?(row.id)
1368 return
1369 }
1370
1371 if row.kind == "line" {
1372 onPressLine?(linePressPayload(for: row, gesture: "tap"))
1373 return
1374 }
1375
1376 guard row.kind == "file" else {
1377 return
1378 }
1379
1380 let rowY = rowOffsets[rowIndex] - verticalOffset
1381 let rect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row))
1382 handleFileHeaderTap(row: row, rect: rect, point: point)
1383 }
1384
1385 @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
1386 guard gesture.state == .began else {
1387 return
1388 }
1389
1390 let point = gesture.location(in: self)
1391 if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) {
1392 return
1393 }
1394
1395 guard let rowIndex = rowIndex(at: verticalOffset + point.y),
1396 rows.indices.contains(rowIndex) else {
1397 return
1398 }
1399
1400 let row = rows[rowIndex]
1401 guard row.kind == "line" else {
1402 return
1403 }
1404
1405 onPressLine?(linePressPayload(for: row, gesture: "longPress"))
1406 }
1407
1408 private func linePressPayload(for row: ReviewDiffNativeRow, gesture: String) -> [String: Any] {
1409 var payload: [String: Any] = [
1410 "rowId": row.id,
1411 "fileId": resolvedFileId(for: row),
1412 "gesture": gesture
1413 ]
1414
1415 if let oldLineNumber = row.oldLineNumber {
1416 payload["oldLineNumber"] = oldLineNumber
1417 }
1418 if let newLineNumber = row.newLineNumber {
1419 payload["newLineNumber"] = newLineNumber
1420 }
1421 if let change = row.change {
1422 payload["change"] = change
1423 }
1424
1425 return payload
1426 }
1427
1428 private func handleFileHeaderTap(row: ReviewDiffNativeRow, rect: CGRect, point: CGPoint) {
1429 let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: rect)
1430 let fileId = resolvedFileId(for: row)
1431
1432 if interactiveRects.checkbox.contains(point) {
1433 onToggleViewedFile?(fileId)
1434 return
1435 }
1436
1437 if rect.contains(point) {
1438 onToggleFile?(fileId)
1439 }
1440 }
1441
1442 func gestureRecognizer(
1443 _ gestureRecognizer: UIGestureRecognizer,
1444 shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
1445 ) -> Bool {
1446 false
1447 }
1448
1449 @objc private func handleHorizontalPan(_ gesture: UIPanGestureRecognizer) {
1450 switch gesture.state {
1451 case .began:
1452 stopHorizontalDeceleration()
1453 let panTarget = horizontalPanTarget(at: gesture.location(in: self))
1454 activePanFileId = panTarget?.fileId
1455 activePanKind = panTarget?.kind
1456 panStartHorizontalOffset = horizontalOffset(for: panTarget?.fileId, kind: panTarget?.kind)
1457 case .changed, .ended, .cancelled:
1458 guard let activePanFileId, let activePanKind else {
1459 return
1460 }
1461 let translation = gesture.translation(in: self)
1462 setHorizontalOffset(
1463 min(
1464 max(panStartHorizontalOffset - translation.x, 0),
1465 maxHorizontalOffset(for: (fileId: activePanFileId, kind: activePanKind))
1466 ),
1467 for: activePanFileId,
1468 kind: activePanKind
1469 )
1470 if gesture.state == .ended, activePanKind == .code {
1471 let velocity = -gesture.velocity(in: self).x
1472 self.activePanFileId = nil
1473 self.activePanKind = nil
1474 startHorizontalDeceleration(fileId: activePanFileId, velocity: velocity)
1475 } else if gesture.state == .cancelled {
1476 self.activePanFileId = nil
1477 self.activePanKind = nil
1478 } else if gesture.state == .ended {
1479 self.activePanFileId = nil
1480 self.activePanKind = nil
1481 }
1482 default:
1483 break
1484 }
1485 }
1486
1487 private func horizontalPanTarget(at point: CGPoint) -> (fileId: String, kind: ReviewDiffHorizontalPanKind)? {
1488 if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) {
1489 return (resolvedFileId(for: stickyHeader.row), .fileHeaderPath)
1490 }
1491
1492 guard let row = row(at: point) else {
1493 guard verticalOffset + point.y >= contentHeight,
1494 fileHeaderRowIndices.isEmpty,
1495 contentWidthsByFileId.count == 1,
1496 let fileId = contentWidthsByFileId.keys.first,
1497 !collapsedFileIds.contains(fileId) else {
1498 return nil
1499 }
1500 return (fileId, .code)
1501 }
1502
1503 let fileId = resolvedFileId(for: row)
1504 if row.kind == "file" {
1505 return (fileId, .fileHeaderPath)
1506 }
1507
1508 return (fileId, .code)
1509 }
1510
1511 private func stickyFileHeaderTarget() -> ReviewDiffStickyFileHeaderTarget? {
1512 guard let stickyHeader = stickyFileHeaderRowIndex() else {
1513 return nil
1514 }
1515
1516 let rowIndex = stickyHeader.rowIndex
1517 let headerTop = rowOffsets[rowIndex]
1518 guard headerTop < verticalOffset + topContentInset else {
1519 return nil
1520 }
1521
1522 let nextHeaderPosition = stickyHeader.position + 1
1523 let nextHeaderRowIndex = fileHeaderRowIndices.indices.contains(nextHeaderPosition)
1524 ? fileHeaderRowIndices[nextHeaderPosition]
1525 : nil
1526 let pushedY: CGFloat
1527 if let nextHeaderRowIndex {
1528 pushedY = min(topContentInset, rowOffsets[nextHeaderRowIndex] - verticalOffset - style.fileHeaderHeight)
1529 } else {
1530 pushedY = topContentInset
1531 }
1532
1533 guard pushedY > topContentInset - style.fileHeaderHeight else {
1534 return nil
1535 }
1536
1537 let rect = CGRect(
1538 x: 0,
1539 y: pushedY,
1540 width: max(bounds.width, viewportWidth),
1541 height: style.fileHeaderHeight
1542 )
1543 return ReviewDiffStickyFileHeaderTarget(rowIndex: rowIndex, row: rows[rowIndex], rect: rect)
1544 }
1545
1546 private func stickyFileHeaderRowIndex() -> ReviewDiffStickyFileHeaderIndex? {
1547 guard !fileHeaderRowIndices.isEmpty else {
1548 return nil
1549 }
1550
1551 var lowerBound = 0
1552 var upperBound = fileHeaderRowIndices.count
1553 while lowerBound < upperBound {
1554 let midpoint = (lowerBound + upperBound) / 2
1555 let rowIndex = fileHeaderRowIndices[midpoint]
1556 if rowOffsets[rowIndex] <= verticalOffset + topContentInset {
1557 lowerBound = midpoint + 1
1558 } else {
1559 upperBound = midpoint
1560 }
1561 }
1562
1563 let matchIndex = lowerBound - 1
1564 guard matchIndex >= 0 else {
1565 return nil
1566 }
1567 return ReviewDiffStickyFileHeaderIndex(position: matchIndex, rowIndex: fileHeaderRowIndices[matchIndex])
1568 }
1569
1570 func scrollAnchor(forFileId fileId: String) -> ReviewDiffScrollAnchor? {
1571 if let stickyHeader = stickyFileHeaderTarget(),
1572 resolvedFileId(for: stickyHeader.row) == fileId {
1573 return ReviewDiffScrollAnchor(fileId: fileId, screenY: topContentInset)
1574 }
1575
1576 guard let headerOffset = fileHeaderOffset(forFileId: fileId) else {
1577 return nil
1578 }
1579
1580 return ReviewDiffScrollAnchor(fileId: fileId, screenY: headerOffset - verticalOffset)
1581 }
1582
1583 func fileHeaderOffset(forFileId fileId: String) -> CGFloat? {
1584 guard let rowIndex = fileHeaderRowIndex(forFileId: fileId) else {
1585 return nil
1586 }
1587
1588 return rowOffsets[rowIndex]
1589 }
1590
1591 func visibleFileId(atVerticalOffset verticalOffset: CGFloat) -> String? {
1592 guard let firstHeaderRowIndex = fileHeaderRowIndices.first else {
1593 return nil
1594 }
1595
1596 var lowerBound = 0
1597 var upperBound = fileHeaderRowIndices.count
1598 while lowerBound < upperBound {
1599 let midpoint = (lowerBound + upperBound) / 2
1600 let rowIndex = fileHeaderRowIndices[midpoint]
1601 if rowOffsets[rowIndex] <= verticalOffset + 0.5 {
1602 lowerBound = midpoint + 1
1603 } else {
1604 upperBound = midpoint
1605 }
1606 }
1607
1608 let rowIndex = lowerBound > 0
1609 ? fileHeaderRowIndices[lowerBound - 1]
1610 : firstHeaderRowIndex
1611 guard rows.indices.contains(rowIndex) else {
1612 return nil
1613 }
1614 return resolvedFileId(for: rows[rowIndex])
1615 }
1616
1617 private func fileHeaderRowIndex(forFileId fileId: String) -> Int? {
1618 fileHeaderRowIndices.first { rowIndex in
1619 rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId
1620 }
1621 }
1622
1623 private func row(at point: CGPoint) -> ReviewDiffNativeRow? {
1624 guard let rowIndex = rowIndex(at: verticalOffset + point.y) else {
1625 return nil
1626 }
1627 guard rows.indices.contains(rowIndex) else {
1628 return nil
1629 }
1630 return rows[rowIndex]
1631 }
1632
1633 private func rowIndex(at absoluteY: CGFloat) -> Int? {
1634 guard !rows.isEmpty else {
1635 return nil
1636 }
1637
1638 var lowerBound = 0
1639 var upperBound = rows.count - 1
1640 while lowerBound <= upperBound {
1641 let midpoint = (lowerBound + upperBound) / 2
1642 let rowStart = rowOffsets[midpoint]
1643 let rowEnd = rowStart + height(at: midpoint)
1644
1645 if absoluteY < rowStart {
1646 upperBound = midpoint - 1
1647 } else if absoluteY >= rowEnd {
1648 lowerBound = midpoint + 1
1649 } else {
1650 return midpoint
1651 }
1652 }
1653
1654 return nil
1655 }
1656
1657 private func firstVisibleRowIndex(atOrAfter absoluteY: CGFloat) -> Int? {
1658 guard !rows.isEmpty else {
1659 return nil
1660 }
1661
1662 var lowerBound = 0
1663 var upperBound = rows.count
1664 while lowerBound < upperBound {
1665 let midpoint = (lowerBound + upperBound) / 2
1666 let rowEnd = rowOffsets[midpoint] + height(at: midpoint)
1667
1668 if rowEnd < absoluteY {
1669 lowerBound = midpoint + 1
1670 } else {
1671 upperBound = midpoint
1672 }
1673 }
1674
1675 return lowerBound < rows.count ? lowerBound : nil
1676 }
1677
1678 private func lastVisibleRowIndex(atOrBefore absoluteY: CGFloat) -> Int? {
1679 guard !rows.isEmpty else {
1680 return nil
1681 }
1682
1683 var lowerBound = 0
1684 var upperBound = rows.count
1685 while lowerBound < upperBound {
1686 let midpoint = (lowerBound + upperBound) / 2
1687 let rowStart = rowOffsets[midpoint]
1688
1689 if rowStart <= absoluteY {
1690 lowerBound = midpoint + 1
1691 } else {
1692 upperBound = midpoint
1693 }
1694 }
1695
1696 let index = lowerBound - 1
1697 return index >= 0 ? index : nil
1698 }
1699
1700 private func resolvedFileId(for row: ReviewDiffNativeRow) -> String {
1701 if let fileId = row.fileId {
1702 return fileId
1703 }
1704 if let range = row.id.range(of: ":header") ?? row.id.range(of: ":hunk:") ?? row.id.range(of: ":line:") {
1705 return String(row.id[..<range.lowerBound])
1706 }
1707 return row.filePath ?? row.id
1708 }
1709
1710 private func horizontalOffset(
1711 for fileId: String?,
1712 kind: ReviewDiffHorizontalPanKind? = .code
1713 ) -> CGFloat {
1714 guard let fileId else {
1715 return 0
1716 }
1717 if kind == .fileHeaderPath {
1718 return headerPathOffsetsByFileId[fileId] ?? 0
1719 }
1720 return horizontalOffsetsByFileId[fileId] ?? 0
1721 }
1722
1723 private func setHorizontalOffset(
1724 _ offset: CGFloat,
1725 for fileId: String,
1726 kind: ReviewDiffHorizontalPanKind = .code
1727 ) {
1728 if kind == .fileHeaderPath {
1729 headerPathOffsetsByFileId[fileId] = offset
1730 setNeedsDisplayForVisibleBounds()
1731 return
1732 }
1733 horizontalOffsetsByFileId[fileId] = offset
1734 setNeedsDisplayForVisibleBounds()
1735 }
1736
1737 private func clampHorizontalOffsets() {
1738 for (fileId, offset) in horizontalOffsetsByFileId {
1739 horizontalOffsetsByFileId[fileId] = min(offset, maxHorizontalOffset(for: (fileId: fileId, kind: .code)))
1740 }
1741 for (fileId, offset) in headerPathOffsetsByFileId {
1742 headerPathOffsetsByFileId[fileId] = min(
1743 offset,
1744 maxHorizontalOffset(for: (fileId: fileId, kind: .fileHeaderPath))
1745 )
1746 }
1747 }
1748
1749 private func maxHorizontalOffset(for target: (fileId: String, kind: ReviewDiffHorizontalPanKind)) -> CGFloat {
1750 if target.kind == .fileHeaderPath,
1751 let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) {
1752 return maxHeaderPathOffset(for: row)
1753 }
1754 if codeWrapColumns != nil {
1755 return 0
1756 }
1757
1758 return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX))
1759 }
1760
1761 private func contentWidth(for fileId: String) -> CGFloat {
1762 contentWidthsByFileId[fileId] ?? min(style.contentWidth, max(viewportWidth, 0))
1763 }
1764
1765 func currentVisibleRowRange() -> (firstRowIndex: Int, lastRowIndex: Int)? {
1766 guard !rows.isEmpty else {
1767 return nil
1768 }
1769
1770 let visibleMinY = verticalOffset
1771 let visibleMaxY = verticalOffset + max(bounds.height, 1)
1772 guard let firstRowIndex = firstVisibleRowIndex(atOrAfter: visibleMinY),
1773 let lastRowIndex = lastVisibleRowIndex(atOrBefore: visibleMaxY),
1774 firstRowIndex <= lastRowIndex else {
1775 return nil
1776 }
1777
1778 return (firstRowIndex: firstRowIndex, lastRowIndex: lastRowIndex)
1779 }
1780
1781 private func maxHeaderPathOffset(for row: ReviewDiffNativeRow) -> CGFloat {
1782 let fullRect = CGRect(x: 0, y: 0, width: max(bounds.width, viewportWidth), height: height(for: row))
1783 let layout = fileHeaderPathLayout(for: row, cardRect: fullRect)
1784 let pathWidth = textWidth(layout.displayPath, font: fileHeaderFont)
1785 return max(0, pathWidth - layout.rect.width)
1786 }
1787
1788 private func startHorizontalDeceleration(fileId: String, velocity: CGFloat) {
1789 guard abs(velocity) > 80 else {
1790 return
1791 }
1792
1793 let currentOffset = horizontalOffset(for: fileId)
1794 let maxOffset = maxHorizontalOffset(for: (fileId: fileId, kind: .code))
1795 if (currentOffset <= 0 && velocity < 0) || (currentOffset >= maxOffset && velocity > 0) {
1796 return
1797 }
1798
1799 stopHorizontalDeceleration()
1800 deceleratingFileId = fileId
1801 horizontalVelocity = velocity
1802 lastDecelerationTimestamp = 0
1803
1804 let displayLink = CADisplayLink(target: self, selector: #selector(stepHorizontalDeceleration(_:)))
1805 displayLink.add(to: .main, forMode: .common)
1806 decelerationDisplayLink = displayLink
1807 }
1808
1809 @objc private func stepHorizontalDeceleration(_ displayLink: CADisplayLink) {
1810 guard let fileId = deceleratingFileId else {
1811 stopHorizontalDeceleration()
1812 return
1813 }
1814
1815 if lastDecelerationTimestamp == 0 {
1816 lastDecelerationTimestamp = displayLink.timestamp
1817 return
1818 }
1819
1820 let dt = max(0, displayLink.timestamp - lastDecelerationTimestamp)
1821 lastDecelerationTimestamp = displayLink.timestamp
1822
1823 let maxOffset = maxHorizontalOffset(for: (fileId: fileId, kind: .code))
1824 let nextOffset = horizontalOffset(for: fileId) + horizontalVelocity * CGFloat(dt)
1825 let clampedOffset = min(max(nextOffset, 0), maxOffset)
1826 setHorizontalOffset(clampedOffset, for: fileId)
1827
1828 // UIScrollView deceleration rates are expressed per millisecond.
1829 horizontalVelocity *= CGFloat(pow(Double(UIScrollView.DecelerationRate.normal.rawValue), dt * 1000))
1830
1831 if abs(horizontalVelocity) < 20 || clampedOffset <= 0 || clampedOffset >= maxOffset {
1832 stopHorizontalDeceleration()
1833 }
1834 }
1835
1836 private func stopHorizontalDeceleration() {
1837 decelerationDisplayLink?.invalidate()
1838 decelerationDisplayLink = nil
1839 deceleratingFileId = nil
1840 horizontalVelocity = 0
1841 lastDecelerationTimestamp = 0
1842 }
1843
1844 deinit {
1845 stopHorizontalDeceleration()
1846 }
1847
1848 override func draw(_ rect: CGRect) {
1849 let drawStartedAt = CACurrentMediaTime()
1850 guard let context = UIGraphicsGetCurrentContext() else {
1851 return
1852 }
1853
1854 theme.background.setFill()
1855 context.fill(rect)
1856
1857 if rows.isEmpty {
1858 drawEmptyState(rect)
1859 return
1860 }
1861
1862 let visibleMinY = verticalOffset + rect.minY
1863 let visibleMaxY = verticalOffset + rect.maxY
1864 let overscan = max(style.rowHeight, style.fileHeaderHeight) * 4
1865 let rangeMinY = max(0, visibleMinY - overscan)
1866 let rangeMaxY = visibleMaxY + overscan
1867 guard let firstRowIndex = firstVisibleRowIndex(atOrAfter: rangeMinY),
1868 let lastRowIndex = lastVisibleRowIndex(atOrBefore: rangeMaxY),
1869 firstRowIndex <= lastRowIndex else {
1870 return
1871 }
1872
1873 var drawnRowCount = 0
1874 for rowIndex in firstRowIndex...lastRowIndex {
1875 let rowStart = rowOffsets[rowIndex]
1876 let rowHeight = height(at: rowIndex)
1877 if rowHeight <= 0 {
1878 continue
1879 }
1880 let rowEnd = rowStart + rowHeight
1881 if rowEnd < visibleMinY || rowStart > visibleMaxY {
1882 continue
1883 }
1884 drawRow(rows[rowIndex], rowIndex: rowIndex, context: context)
1885 drawnRowCount += 1
1886 }
1887 drawStickyFileHeader(context: context)
1888
1889 maybeEmitDrawMetrics(
1890 drawnRowCount: drawnRowCount,
1891 durationMs: (CACurrentMediaTime() - drawStartedAt) * 1000,
1892 firstRowIndex: firstRowIndex,
1893 lastRowIndex: lastRowIndex
1894 )
1895 }
1896
1897 private func maybeEmitDrawMetrics(
1898 drawnRowCount: Int,
1899 durationMs: Double,
1900 firstRowIndex: Int,
1901 lastRowIndex: Int
1902 ) {
1903 guard !isVerticalScrollActive else {
1904 return
1905 }
1906
1907 let now = CACurrentMediaTime()
1908 guard now - lastDrawMetricsTimestamp >= 1 else {
1909 return
1910 }
1911
1912 lastDrawMetricsTimestamp = now
1913 onDrawMetrics?([
1914 "drawnRows": drawnRowCount,
1915 "durationMs": durationMs,
1916 "firstRowIndex": firstRowIndex,
1917 "lastRowIndex": lastRowIndex,
1918 "scannedRows": lastRowIndex - firstRowIndex + 1,
1919 "totalRows": rows.count,
1920 ])
1921 }
1922
1923 private func drawEmptyState(_ rect: CGRect) {
1924 let message = "No native diff rows"
1925 let attributes: [NSAttributedString.Key: Any] = [
1926 .font: emptyStateFont,
1927 .foregroundColor: theme.mutedText,
1928 ]
1929 message.draw(at: CGPoint(x: 16, y: rect.minY + 16), withAttributes: attributes)
1930 }
1931
1932 private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) {
1933 let rowY = rowOffsets[rowIndex] - verticalOffset
1934 let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(at: rowIndex))
1935
1936 switch row.kind {
1937 case "file":
1938 drawFileRow(row, rect: fullRect, context: context)
1939 case "hunk":
1940 drawHunkRow(row, rect: fullRect, context: context)
1941 case "notice":
1942 drawNoticeRow(row, rect: fullRect, context: context)
1943 case "comment":
1944 drawCommentRow(row, rect: fullRect, context: context)
1945 default:
1946 drawCodeRow(row, rect: fullRect, context: context)
1947 }
1948 }
1949
1950 private func drawStickyFileHeader(context: CGContext) {
1951 guard let stickyHeader = stickyFileHeaderTarget() else {
1952 return
1953 }
1954
1955 drawFileRow(stickyHeader.row, rect: stickyHeader.rect, context: context)
1956 }
1957
1958 private func drawFileRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
1959 theme.background.setFill()
1960 context.fill(rect)
1961
1962 let cardRect = rect
1963 theme.headerBackground.setFill()
1964 context.fill(cardRect)
1965
1966 let hairline = 1 / traitCollection.displayScale
1967 theme.border.setFill()
1968 context.fill(CGRect(x: cardRect.minX, y: cardRect.maxY - hairline, width: cardRect.width, height: hairline))
1969
1970 let centerY = cardRect.midY
1971 let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: cardRect)
1972 let fileId = resolvedFileId(for: row)
1973 drawDisclosureChevron(
1974 rect: interactiveRects.chevron,
1975 color: theme.mutedText,
1976 collapsed: collapsedFileIds.contains(fileId)
1977 )
1978
1979 drawFileIcon(rect: interactiveRects.icon, changeType: row.changeType)
1980
1981 drawViewedCheckbox(rect: interactiveRects.checkbox, checked: viewedFileIds.contains(fileId))
1982
1983 let deletions = row.deletions ?? 0
1984 let additions = row.additions ?? 0
1985 let deleteText = "-\(deletions)"
1986 let addText = "+\(additions)"
1987 let deleteWidth = textWidth(deleteText, font: fileHeaderMetaFont)
1988 let addWidth = textWidth(addText, font: fileHeaderMetaFont)
1989 let countsGap = min(style.fileHeaderCountGap, 4)
1990 let countsWidth = deleteWidth + countsGap + addWidth
1991 let countsX = interactiveRects.checkbox.minX - 10 - countsWidth
1992 drawSingleLineText(
1993 deleteText,
1994 rect: CGRect(x: countsX, y: centerY - 9, width: deleteWidth, height: 18),
1995 color: theme.deleteText,
1996 font: fileHeaderMetaFont
1997 )
1998 drawSingleLineText(
1999 addText,
2000 rect: CGRect(x: countsX + deleteWidth + countsGap, y: centerY - 9, width: addWidth, height: 18),
2001 color: theme.addText,
2002 font: fileHeaderMetaFont
2003 )
2004
2005 let pathLayout = fileHeaderPathLayout(for: row, cardRect: cardRect)
2006 let pathOffset = horizontalOffset(for: resolvedFileId(for: row), kind: .fileHeaderPath)
2007 drawSingleLineText(
2008 pathLayout.displayPath,
2009 rect: pathLayout.rect,
2010 color: theme.text,
2011 font: fileHeaderFont,
2012 horizontalOffset: pathOffset
2013 )
2014 drawFileHeaderPathScrollFade(row, pathRect: pathLayout.rect, horizontalOffset: pathOffset, context: context)
2015 }
2016
2017 private func drawNoticeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
2018 guard !collapsedFileIds.contains(resolvedFileId(for: row)) else {
2019 return
2020 }
2021
2022 theme.background.setFill()
2023 context.fill(rect)
2024
2025 let hairline = 1 / traitCollection.displayScale
2026 theme.border.withAlphaComponent(0.65).setFill()
2027 context.fill(CGRect(x: 0, y: rect.maxY - hairline, width: rect.width, height: hairline))
2028
2029 let iconSize: CGFloat = 16
2030 let iconRect = CGRect(
2031 x: style.fileHeaderHorizontalPadding + 2,
2032 y: rect.midY - iconSize / 2,
2033 width: iconSize,
2034 height: iconSize
2035 )
2036 drawNoticeIcon(rect: iconRect, color: theme.mutedText)
2037
2038 drawSingleLineText(
2039 row.text ?? "",
2040 rect: CGRect(
2041 x: iconRect.maxX + 10,
2042 y: rect.midY - fileHeaderSubtextFont.lineHeight / 2,
2043 width: max(24, viewportWidth - iconRect.maxX - 10 - style.fileHeaderHorizontalPadding),
2044 height: fileHeaderSubtextFont.lineHeight
2045 ),
2046 color: theme.mutedText,
2047 font: fileHeaderSubtextFont
2048 )
2049 }
2050
2051 private func drawCommentRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
2052 guard !collapsedFileIds.contains(resolvedFileId(for: row)) else {
2053 return
2054 }
2055
2056 theme.background.setFill()
2057 context.fill(rect)
2058
2059 let isCollapsed = collapsedCommentIds.contains(row.id)
2060 let cardInset = CGFloat(8)
2061 let cardRect = rect.insetBy(dx: cardInset, dy: 5)
2062 let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 10)
2063 theme.headerBackground.setFill()
2064 cardPath.fill()
2065 theme.border.withAlphaComponent(0.85).setStroke()
2066 cardPath.lineWidth = 1
2067 cardPath.stroke()
2068
2069 let chevronRect = CGRect(x: cardRect.minX + 10, y: cardRect.minY + 11, width: 16, height: 16)
2070 drawDisclosureChevron(rect: chevronRect, color: theme.mutedText, collapsed: isCollapsed)
2071
2072 let title = "Comment on \(row.commentRangeLabel ?? "line")"
2073 drawSingleLineText(
2074 title,
2075 rect: CGRect(
2076 x: chevronRect.maxX + 10,
2077 y: cardRect.minY + 8,
2078 width: max(24, cardRect.width - chevronRect.maxX - 28),
2079 height: fileHeaderSubtextFont.lineHeight + 4
2080 ),
2081 color: theme.mutedText,
2082 font: fileHeaderSubtextFont
2083 )
2084
2085 guard !isCollapsed else {
2086 return
2087 }
2088
2089 let body = row.commentText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
2090 drawMultilineText(
2091 body.isEmpty ? "Comment" : body,
2092 rect: CGRect(
2093 x: cardRect.minX + 18,
2094 y: cardRect.minY + 42,
2095 width: max(24, cardRect.width - 36),
2096 height: max(20, cardRect.height - 56)
2097 ),
2098 color: theme.text,
2099 font: fileHeaderSubtextFont,
2100 maximumLineCount: 3
2101 )
2102 }
2103
2104 private func fileHeaderPathLayout(
2105 for row: ReviewDiffNativeRow,
2106 cardRect: CGRect
2107 ) -> ReviewDiffFileHeaderPathLayout {
2108 let centerY = cardRect.midY
2109 let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: cardRect)
2110
2111 let deletions = row.deletions ?? 0
2112 let additions = row.additions ?? 0
2113 let deleteWidth = textWidth("-\(deletions)", font: fileHeaderMetaFont)
2114 let addWidth = textWidth("+\(additions)", font: fileHeaderMetaFont)
2115 let countsGap = min(style.fileHeaderCountGap, 4)
2116 let countsWidth = deleteWidth + countsGap + addWidth
2117 let countsX = interactiveRects.checkbox.minX - 10 - countsWidth
2118 let pathX = interactiveRects.icon.maxX + 10
2119 let pathWidth = max(CGFloat(24), countsX - pathX - 12)
2120 let displayPath: String
2121 if let previousPath = row.previousPath, previousPath != row.filePath, let filePath = row.filePath {
2122 displayPath = "\(previousPath) -> \(filePath)"
2123 } else {
2124 displayPath = row.filePath ?? ""
2125 }
2126
2127 return ReviewDiffFileHeaderPathLayout(
2128 displayPath: displayPath,
2129 rect: CGRect(x: pathX, y: centerY - 10, width: pathWidth, height: 20)
2130 )
2131 }
2132
2133 private func fileHeaderInteractiveRects(
2134 for row: ReviewDiffNativeRow,
2135 cardRect: CGRect
2136 ) -> ReviewDiffFileHeaderInteractiveRects {
2137 let centerY = cardRect.midY
2138 let horizontalPadding = style.fileHeaderHorizontalPadding
2139 let chevronRect = CGRect(x: cardRect.minX + horizontalPadding, y: centerY - 10, width: 20, height: 20)
2140 let iconRect = CGRect(x: chevronRect.maxX + 8, y: centerY - 10, width: 20, height: 20)
2141 let checkboxRect = CGRect(x: cardRect.maxX - horizontalPadding - 20, y: centerY - 10, width: 20, height: 20)
2142 return ReviewDiffFileHeaderInteractiveRects(
2143 chevron: chevronRect,
2144 icon: iconRect,
2145 checkbox: checkboxRect
2146 )
2147 }
2148
2149 private func fileStatusColor(_ changeType: String?) -> UIColor {
2150 switch changeType {
2151 case "new":
2152 return theme.addText
2153 case "deleted":
2154 return theme.deleteText
2155 case "renamed", "rename-pure", "rename-changed":
2156 return theme.hunkText
2157 default:
2158 return theme.hunkText
2159 }
2160 }
2161
2162 private func drawFileIcon(rect: CGRect, changeType: String?) {
2163 let color = fileStatusColor(changeType)
2164 let outerPath = UIBezierPath(roundedRect: rect, cornerRadius: 6)
2165 color.setStroke()
2166 outerPath.lineWidth = 2
2167 outerPath.stroke()
2168
2169 if isRenameChange(changeType) {
2170 drawRenameChevronIcon(rect: rect.insetBy(dx: 4.5, dy: 5), color: color)
2171 return
2172 }
2173
2174 let dotRect = CGRect(x: rect.midX - 3, y: rect.midY - 3, width: 6, height: 6)
2175 color.setFill()
2176 UIBezierPath(ovalIn: dotRect).fill()
2177 }
2178
2179 private func isRenameChange(_ changeType: String?) -> Bool {
2180 changeType == "renamed" || changeType == "rename-pure" || changeType == "rename-changed"
2181 }
2182
2183 private func drawDisclosureChevron(rect: CGRect, color: UIColor, collapsed: Bool) {
2184 guard let context = UIGraphicsGetCurrentContext() else {
2185 return
2186 }
2187
2188 context.saveGState()
2189 context.setStrokeColor(color.cgColor)
2190 context.setLineWidth(2)
2191 context.setLineCap(.round)
2192 context.setLineJoin(.round)
2193 if collapsed {
2194 context.move(to: CGPoint(x: rect.minX + rect.width * 0.40, y: rect.minY + rect.height * 0.28))
2195 context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.60, y: rect.midY))
2196 context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.40, y: rect.maxY - rect.height * 0.28))
2197 } else {
2198 context.move(to: CGPoint(x: rect.minX + rect.width * 0.28, y: rect.minY + rect.height * 0.42))
2199 context.addLine(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.62))
2200 context.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.28, y: rect.minY + rect.height * 0.42))
2201 }
2202 context.strokePath()
2203 context.restoreGState()
2204 }
2205
2206 private func drawRenameChevronIcon(rect: CGRect, color: UIColor) {
2207 guard let context = UIGraphicsGetCurrentContext() else {
2208 return
2209 }
2210
2211 context.saveGState()
2212 context.setStrokeColor(color.cgColor)
2213 context.setLineWidth(1.8)
2214 context.setLineCap(.round)
2215 context.setLineJoin(.round)
2216
2217 let chevronWidth = min(rect.width * 0.28, 3.6)
2218 let chevronHeight = min(rect.height, 8)
2219 let gap = min(rect.width * 0.18, 2.4)
2220 let totalWidth = chevronWidth * 2 + gap
2221 let startX = rect.midX - totalWidth / 2
2222 let topY = rect.midY - chevronHeight / 2
2223 let bottomY = rect.midY + chevronHeight / 2
2224
2225 for x in [startX, startX + chevronWidth + gap] {
2226 context.move(to: CGPoint(x: x, y: topY))
2227 context.addLine(to: CGPoint(x: x + chevronWidth, y: rect.midY))
2228 context.addLine(to: CGPoint(x: x, y: bottomY))
2229 }
2230
2231 context.strokePath()
2232 context.restoreGState()
2233 }
2234
2235 private func drawViewedCheckbox(rect: CGRect, checked: Bool) {
2236 let path = UIBezierPath(roundedRect: rect, cornerRadius: 6)
2237 if checked {
2238 theme.hunkText.setFill()
2239 path.fill()
2240 }
2241 (checked ? theme.hunkText : theme.mutedText).setStroke()
2242 path.lineWidth = 1.8
2243 path.stroke()
2244
2245 guard checked, let context = UIGraphicsGetCurrentContext() else {
2246 return
2247 }
2248
2249 context.saveGState()
2250 context.setStrokeColor(theme.background.cgColor)
2251 context.setLineWidth(2)
2252 context.setLineCap(.round)
2253 context.setLineJoin(.round)
2254 context.move(to: CGPoint(x: rect.minX + rect.width * 0.28, y: rect.midY))
2255 context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.44, y: rect.maxY - rect.height * 0.30))
2256 context.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.25, y: rect.minY + rect.height * 0.30))
2257 context.strokePath()
2258 context.restoreGState()
2259 }
2260
2261 private func drawNoticeIcon(rect: CGRect, color: UIColor) {
2262 guard let context = UIGraphicsGetCurrentContext() else {
2263 return
2264 }
2265
2266 context.saveGState()
2267 context.setStrokeColor(color.cgColor)
2268 context.setLineWidth(1.7)
2269 context.setLineCap(.round)
2270 context.setLineJoin(.round)
2271
2272 context.strokeEllipse(in: rect.insetBy(dx: 1, dy: 1))
2273 context.move(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.30))
2274 context.addLine(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.58))
2275 context.strokePath()
2276 color.setFill()
2277 context.fillEllipse(in: CGRect(x: rect.midX - 1, y: rect.maxY - rect.height * 0.30, width: 2, height: 2))
2278 context.restoreGState()
2279 }
2280
2281 private func drawCenteredText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
2282 let paragraphStyle = NSMutableParagraphStyle()
2283 paragraphStyle.alignment = .center
2284 let attributes: [NSAttributedString.Key: Any] = [
2285 .font: font,
2286 .foregroundColor: color,
2287 .paragraphStyle: paragraphStyle,
2288 ]
2289 (text as NSString).draw(in: rect, withAttributes: attributes)
2290 }
2291
2292 private func drawHunkRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
2293 let fileId = resolvedFileId(for: row)
2294 let horizontalOffset = horizontalOffset(for: fileId)
2295 let contentWidth = contentWidth(for: fileId)
2296 theme.hunkBackground.setFill()
2297 context.fill(rect)
2298
2299 context.saveGState()
2300 context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight))
2301 drawText(
2302 row.text ?? "",
2303 rect: CGRect(
2304 x: codeStartX - horizontalOffset,
2305 y: centeredTextY(in: rect, font: hunkFont),
2306 width: contentWidth,
2307 height: hunkFont.lineHeight
2308 ),
2309 color: theme.hunkText,
2310 font: hunkFont
2311 )
2312 context.restoreGState()
2313 }
2314
2315 private func drawCodeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
2316 let fileId = resolvedFileId(for: row)
2317 let horizontalOffset = horizontalOffset(for: fileId)
2318 let contentWidth = contentWidth(for: fileId)
2319 let change = row.change ?? "context"
2320 // Wrapped rows keep the line number and first code line in the first row-height band.
2321 let layout = codeLayoutsByRowId[row.id]
2322 let firstLineRect = CGRect(
2323 x: rect.minX, y: rect.minY, width: rect.width,
2324 height: max(style.rowHeight, layout?.firstLineHeight ?? 0)
2325 )
2326 rowBackground(for: change).setFill()
2327 context.fill(rect)
2328
2329 if change == "add" {
2330 theme.addBar.setFill()
2331 context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height))
2332 } else if change == "delete" {
2333 drawDeleteStripes(
2334 rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height),
2335 context: context
2336 )
2337 }
2338
2339 drawSelectionOverlay(row, rect: rect, context: context)
2340
2341 let lineNumber = row.newLineNumber ?? row.oldLineNumber
2342 if let lineNumber {
2343 drawRightAlignedText(
2344 "\(lineNumber)",
2345 rect: CGRect(
2346 x: style.changeBarWidth,
2347 y: centeredTextY(in: firstLineRect, font: lineNumberFont),
2348 width: style.gutterWidth - style.codePadding,
2349 height: lineNumberFont.lineHeight
2350 ),
2351 color: lineNumberColor(for: change),
2352 font: lineNumberFont
2353 )
2354 }
2355
2356 context.saveGState()
2357 context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: rect.height))
2358 let codeTextRect = CGRect(
2359 x: codeStartX - horizontalOffset,
2360 y: centeredTextY(in: firstLineRect, font: codeFont),
2361 width: contentWidth,
2362 height: codeFont.lineHeight
2363 )
2364 if let layout, layout.usesNativeLayout {
2365 let text = tokensByRowId[row.id].map {
2366 tokenAttributedString(rowId: row.id, tokens: $0, fallbackColor: theme.text, font: codeFont)
2367 } ?? NSAttributedString(string: row.content ?? "", attributes: [.foregroundColor: theme.text])
2368 let highlights = (change == "add" || change == "delete") ? (row.wordDiffRanges ?? []) : []
2369 layout.decorate(
2370 text: text,
2371 highlights: highlights.filter { $0.start >= 0 && $0.end > $0.start }.map {
2372 NSRange(location: $0.start, length: $0.end - $0.start)
2373 },
2374 color: (change == "add" ? theme.addBar : theme.deleteBar).withAlphaComponent(0.28),
2375 version: codeDecorationVersion
2376 )
2377 layout.draw(
2378 at: CGPoint(x: codeStartX, y: rect.minY + max(0, (firstLineRect.height - layout.firstLineHeight) / 2)),
2379 clip: context.boundingBoxOfClipPath
2380 )
2381 context.restoreGState()
2382 return
2383 }
2384 let lineStarts = layout?.starts ?? [0]
2385 drawWordDiffRanges(
2386 row,
2387 lineStarts: lineStarts,
2388 firstLineRect: firstLineRect,
2389 context: context,
2390 horizontalOffset: horizontalOffset
2391 )
2392 if let tokens = tokensByRowId[row.id], !tokens.isEmpty {
2393 let attributedText = tokenAttributedString(
2394 rowId: row.id,
2395 tokens: tokens,
2396 fallbackColor: theme.text,
2397 font: codeFont
2398 )
2399 drawCodeLines(length: attributedText.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in
2400 let segment = range.length == attributedText.length
2401 ? attributedText
2402 : attributedText.attributedSubstring(from: range)
2403 segment.draw(in: lineRect)
2404 }
2405 } else {
2406 let content = (row.content ?? "") as NSString
2407 drawCodeLines(length: content.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in
2408 let segment = range.length == content.length ? content as String : content.substring(with: range)
2409 drawText(segment, rect: lineRect, color: theme.text, font: codeFont)
2410 }
2411 }
2412 context.restoreGState()
2413 }
2414
2415 /// Draws the segment starting at each of the row's line starts on its own visual line.
2416 private func drawCodeLines(
2417 length: Int,
2418 lineStarts: [Int],
2419 firstLineRect: CGRect,
2420 draw: (NSRange, CGRect) -> Void
2421 ) {
2422 var lineRect = firstLineRect
2423 let clip = UIGraphicsGetCurrentContext()?.boundingBoxOfClipPath ?? bounds
2424 let first = max(0, Int(floor((clip.minY - firstLineRect.minY) / codeWrapLineHeight)))
2425 let last = min(lineStarts.count, Int(ceil((clip.maxY - firstLineRect.minY) / codeWrapLineHeight)))
2426 guard first < last else { return }
2427 lineRect.origin.y += CGFloat(first) * codeWrapLineHeight
2428 for line in first..<last {
2429 let lineStart = lineStarts[line]
2430 let lineEnd = line + 1 < lineStarts.count ? lineStarts[line + 1] : length
2431 draw(NSRange(location: lineStart, length: lineEnd - lineStart), lineRect)
2432 lineRect.origin.y += codeWrapLineHeight
2433 }
2434 }
2435
2436 private func drawSelectionOverlay(
2437 _ row: ReviewDiffNativeRow,
2438 rect: CGRect,
2439 context: CGContext
2440 ) {
2441 guard selectedRowIds.contains(row.id) else {
2442 return
2443 }
2444
2445 // Keep the diff and syntax colors intact; selection lives in the gutter.
2446 theme.hunkText.withAlphaComponent(0.95).setFill()
2447 context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height))
2448 }
2449
2450 private func drawWordDiffRanges(
2451 _ row: ReviewDiffNativeRow,
2452 lineStarts: [Int],
2453 firstLineRect: CGRect,
2454 context: CGContext,
2455 horizontalOffset: CGFloat
2456 ) {
2457 guard let ranges = row.wordDiffRanges, !ranges.isEmpty else {
2458 return
2459 }
2460
2461 let change = row.change ?? "context"
2462 guard change == "add" || change == "delete" else {
2463 return
2464 }
2465
2466 let fillColor: UIColor
2467 if change == "add" {
2468 fillColor = theme.addBar.withAlphaComponent(0.28)
2469 } else {
2470 fillColor = theme.deleteBar.withAlphaComponent(0.28)
2471 }
2472 let highlightHeight = max(4, min(firstLineRect.height - 4, codeFont.lineHeight))
2473 let highlightY = firstLineRect.midY - highlightHeight / 2
2474
2475 fillColor.setFill()
2476 for range in ranges {
2477 guard range.end > range.start else {
2478 continue
2479 }
2480
2481 // A wrapped row splits the highlight at each visual line boundary.
2482 for (line, lineStart) in lineStarts.enumerated() {
2483 let lineEnd = line + 1 < lineStarts.count ? lineStarts[line + 1] : Int.max
2484 let start = max(range.start, lineStart)
2485 let end = min(range.end, lineEnd)
2486 guard end > start else {
2487 continue
2488 }
2489 let highlightRect = CGRect(
2490 x: codeStartX - horizontalOffset + CGFloat(start - lineStart) * codeCharacterWidth,
2491 y: highlightY + CGFloat(line) * codeWrapLineHeight,
2492 width: max(2, CGFloat(end - start) * codeCharacterWidth),
2493 height: highlightHeight
2494 )
2495 UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill()
2496 }
2497 }
2498 }
2499
2500 private func rowBackground(for change: String) -> UIColor {
2501 if change == "add" {
2502 return theme.addBackground
2503 }
2504 if change == "delete" {
2505 return theme.deleteBackground
2506 }
2507 return theme.background
2508 }
2509
2510 private func lineNumberColor(for change: String) -> UIColor {
2511 if change == "add" {
2512 return theme.addText
2513 }
2514 if change == "delete" {
2515 return theme.deleteText
2516 }
2517 return theme.mutedText
2518 }
2519
2520 private func drawDeleteStripes(rect: CGRect, context: CGContext) {
2521 theme.deleteBar.setFill()
2522 var y = rect.minY
2523 while y < rect.maxY {
2524 context.fill(CGRect(x: rect.minX, y: y, width: rect.width, height: 1))
2525 y += 2
2526 }
2527 }
2528
2529 private func drawText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
2530 let attributes: [NSAttributedString.Key: Any] = [
2531 .font: font,
2532 .foregroundColor: color,
2533 .ligature: 0,
2534 ]
2535 (text as NSString).draw(in: rect, withAttributes: attributes)
2536 }
2537
2538 private func drawMultilineText(
2539 _ text: String,
2540 rect: CGRect,
2541 color: UIColor,
2542 font: UIFont,
2543 maximumLineCount: Int
2544 ) {
2545 let paragraphStyle = NSMutableParagraphStyle()
2546 paragraphStyle.lineBreakMode = .byTruncatingTail
2547 paragraphStyle.lineSpacing = 2
2548
2549 let attributes: [NSAttributedString.Key: Any] = [
2550 .font: font,
2551 .foregroundColor: color,
2552 .paragraphStyle: paragraphStyle,
2553 .ligature: 0,
2554 ]
2555 let maxHeight = CGFloat(maximumLineCount) * (font.lineHeight + paragraphStyle.lineSpacing)
2556 (text as NSString).draw(
2557 in: CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: min(rect.height, maxHeight)),
2558 withAttributes: attributes
2559 )
2560 }
2561
2562 private func centeredTextY(in rect: CGRect, font: UIFont) -> CGFloat {
2563 rect.midY - font.lineHeight / 2
2564 }
2565
2566 private func drawSingleLineText(
2567 _ text: String,
2568 rect: CGRect,
2569 color: UIColor,
2570 font: UIFont,
2571 horizontalOffset: CGFloat = 0
2572 ) {
2573 guard let context = UIGraphicsGetCurrentContext() else {
2574 return
2575 }
2576
2577 let attributes: [NSAttributedString.Key: Any] = [
2578 .font: font,
2579 .foregroundColor: color,
2580 .ligature: 0,
2581 ]
2582 context.saveGState()
2583 context.clip(to: rect)
2584 let textY = rect.midY - font.lineHeight / 2
2585 (text as NSString).draw(
2586 at: CGPoint(x: rect.minX - horizontalOffset, y: textY),
2587 withAttributes: attributes
2588 )
2589 context.restoreGState()
2590 }
2591
2592 private func drawFileHeaderPathScrollFade(
2593 _ row: ReviewDiffNativeRow,
2594 pathRect: CGRect,
2595 horizontalOffset: CGFloat,
2596 context: CGContext
2597 ) {
2598 let maxOffset = maxHeaderPathOffset(for: row)
2599 guard maxOffset > 0, pathRect.width > 0 else {
2600 return
2601 }
2602
2603 let fadeWidth = min(CGFloat(28), pathRect.width / 3)
2604 if horizontalOffset > 0.5 {
2605 drawHorizontalFade(
2606 rect: CGRect(x: pathRect.minX, y: pathRect.minY, width: fadeWidth, height: pathRect.height),
2607 color: theme.headerBackground,
2608 fadesToRight: false,
2609 context: context
2610 )
2611 }
2612
2613 if horizontalOffset < maxOffset - 0.5 {
2614 drawHorizontalFade(
2615 rect: CGRect(x: pathRect.maxX - fadeWidth, y: pathRect.minY, width: fadeWidth, height: pathRect.height),
2616 color: theme.headerBackground,
2617 fadesToRight: true,
2618 context: context
2619 )
2620 }
2621 }
2622
2623 private func drawHorizontalFade(
2624 rect: CGRect,
2625 color: UIColor,
2626 fadesToRight: Bool,
2627 context: CGContext
2628 ) {
2629 guard rect.width > 0,
2630 let gradient = CGGradient(
2631 colorsSpace: CGColorSpaceCreateDeviceRGB(),
2632 colors: [
2633 color.withAlphaComponent(fadesToRight ? 0 : 1).cgColor,
2634 color.withAlphaComponent(fadesToRight ? 1 : 0).cgColor,
2635 ] as CFArray,
2636 locations: [0, 1]
2637 ) else {
2638 return
2639 }
2640
2641 context.saveGState()
2642 context.clip(to: rect)
2643 context.drawLinearGradient(
2644 gradient,
2645 start: CGPoint(x: rect.minX, y: rect.midY),
2646 end: CGPoint(x: rect.maxX, y: rect.midY),
2647 options: []
2648 )
2649 context.restoreGState()
2650 }
2651
2652 private func textWidth(_ text: String, font: UIFont) -> CGFloat {
2653 let attributes: [NSAttributedString.Key: Any] = [.font: font, .ligature: 0]
2654 return ceil((text as NSString).size(withAttributes: attributes).width)
2655 }
2656
2657 private func monospaceCharacterWidth(font: UIFont) -> CGFloat {
2658 let sampleLength = 64
2659 let sample = String(repeating: "M", count: sampleLength)
2660 let attributes: [NSAttributedString.Key: Any] = [.font: font, .ligature: 0]
2661 return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength)
2662 }
2663
2664 private func tokenAttributedString(
2665 rowId: String,
2666 tokens: [ReviewDiffNativeToken],
2667 fallbackColor: UIColor,
2668 font: UIFont
2669 ) -> NSAttributedString {
2670 if let cached = tokenAttributedStringsByRowId[rowId] {
2671 return cached
2672 }
2673
2674 let attributedString = NSMutableAttributedString(string: "")
2675 for token in tokens where !token.content.isEmpty {
2676 attributedString.append(
2677 NSAttributedString(
2678 string: token.content,
2679 attributes: [
2680 .font: font,
2681 .foregroundColor: tokenColor(for: token.color, fallbackColor: fallbackColor),
2682 .ligature: 0,
2683 ]
2684 )
2685 )
2686 }
2687
2688 tokenAttributedStringsByRowId[rowId] = attributedString
2689 return attributedString
2690 }
2691
2692 private func tokenColor(for hex: String?, fallbackColor: UIColor) -> UIColor {
2693 guard let hex, !hex.isEmpty else {
2694 return fallbackColor
2695 }
2696
2697 if let color = tokenColorsByHex[hex] {
2698 return color
2699 }
2700
2701 guard let color = UIColor(reviewDiffHex: hex) else {
2702 return fallbackColor
2703 }
2704
2705 tokenColorsByHex[hex] = color
2706 return color
2707 }
2708
2709 private func drawRightAlignedText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
2710 let paragraphStyle = NSMutableParagraphStyle()
2711 paragraphStyle.alignment = .right
2712 let attributes: [NSAttributedString.Key: Any] = [
2713 .font: font,
2714 .foregroundColor: color,
2715 .paragraphStyle: paragraphStyle,
2716 ]
2717 (text as NSString).draw(in: rect, withAttributes: attributes)
2718 }
2719}
2720
2721private extension UIColor {
2722 convenience init?(reviewDiffHex hex: String?) {
2723 guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
2724 return nil
2725 }
2726
2727 if value.hasPrefix("#") {
2728 value.removeFirst()
2729 }
2730
2731 guard value.count == 6 || value.count == 8 else {
2732 return nil
2733 }
2734
2735 var rawValue: UInt64 = 0
2736 guard Scanner(string: value).scanHexInt64(&rawValue) else {
2737 return nil
2738 }
2739
2740 let red: CGFloat
2741 let green: CGFloat
2742 let blue: CGFloat
2743 let alpha: CGFloat
2744
2745 if value.count == 8 {
2746 red = CGFloat((rawValue >> 24) & 0xff) / 255
2747 green = CGFloat((rawValue >> 16) & 0xff) / 255
2748 blue = CGFloat((rawValue >> 8) & 0xff) / 255
2749 alpha = CGFloat(rawValue & 0xff) / 255
2750 } else {
2751 red = CGFloat((rawValue >> 16) & 0xff) / 255
2752 green = CGFloat((rawValue >> 8) & 0xff) / 255
2753 blue = CGFloat(rawValue & 0xff) / 255
2754 alpha = 1
2755 }
2756
2757 self.init(red: red, green: green, blue: blue, alpha: alpha)
2758 }
2759}