| 1 | import { Platform, Text as NativeText, View } from "react-native"; |
| 2 | |
| 3 | import { cn } from "../../lib/cn"; |
| 4 | import { MOBILE_CODE_SURFACE } from "../../lib/typography"; |
| 5 | |
| 6 | import type { ReviewRenderableLineRow } from "./reviewModel"; |
| 7 | import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; |
| 8 | |
| 9 | export const REVIEW_MONO_FONT_FAMILY = Platform.select({ |
| 10 | ios: "ui-monospace", |
| 11 | android: "monospace", |
| 12 | default: "monospace", |
| 13 | }); |
| 14 | |
| 15 | export const REVIEW_DIFF_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; |
| 16 | |
| 17 | export function renderVisibleWhitespace(value: string): string { |
| 18 | const expandedTabs = value.replace(/\t/g, " "); |
| 19 | return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0")); |
| 20 | } |
| 21 | |
| 22 | export function changeTone(change: ReviewRenderableLineRow["change"]): string { |
| 23 | if (change === "add") return "bg-emerald-500/10"; |
| 24 | if (change === "delete") return "bg-rose-500/10"; |
| 25 | return "bg-card"; |
| 26 | } |
| 27 | |
| 28 | export function changeBarTone(change: ReviewRenderableLineRow["change"]): string { |
| 29 | if (change === "add") return "bg-emerald-400"; |
| 30 | if (change === "delete") return "bg-rose-400"; |
| 31 | return "bg-border/50"; |
| 32 | } |
| 33 | |
| 34 | function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string | undefined { |
| 35 | if (change === "add") return "rgba(16, 185, 129, 0.24)"; |
| 36 | if (change === "delete") return "rgba(244, 63, 94, 0.24)"; |
| 37 | return undefined; |
| 38 | } |
| 39 | |
| 40 | export function ReviewChangeBar(props: { |
| 41 | readonly change: ReviewRenderableLineRow["change"]; |
| 42 | readonly height?: number; |
| 43 | }) { |
| 44 | const height = props.height ?? REVIEW_DIFF_LINE_HEIGHT; |
| 45 | if (props.change === "delete") { |
| 46 | return ( |
| 47 | <View className="w-[5px] overflow-hidden" style={{ height }}> |
| 48 | <View> |
| 49 | {Array.from({ length: Math.ceil(height / 2) }, (_, index) => ( |
| 50 | <View key={index}> |
| 51 | <View className="h-px w-[5px] bg-rose-400" /> |
| 52 | <View className="h-px" /> |
| 53 | </View> |
| 54 | ))} |
| 55 | </View> |
| 56 | </View> |
| 57 | ); |
| 58 | } |
| 59 | |
| 60 | return ( |
| 61 | <View className="w-[5px] overflow-hidden" style={{ height }}> |
| 62 | <View className={cn("h-full w-[5px] flex-1", changeBarTone(props.change))} /> |
| 63 | </View> |
| 64 | ); |
| 65 | } |
| 66 | |
| 67 | export function DiffTokenText(props: { |
| 68 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; |
| 69 | readonly fallback: string; |
| 70 | readonly change?: ReviewRenderableLineRow["change"]; |
| 71 | readonly className?: string; |
| 72 | readonly fontSize?: number; |
| 73 | readonly lineHeight?: number; |
| 74 | }) { |
| 75 | const fontSize = props.fontSize ?? MOBILE_CODE_SURFACE.fontSize; |
| 76 | const lineHeight = props.lineHeight ?? MOBILE_CODE_SURFACE.rowHeight; |
| 77 | if (!props.tokens || props.tokens.length === 0) { |
| 78 | return ( |
| 79 | <NativeText |
| 80 | numberOfLines={1} |
| 81 | selectable |
| 82 | className={cn("font-normal text-foreground", props.className)} |
| 83 | style={{ |
| 84 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| 85 | fontSize, |
| 86 | lineHeight, |
| 87 | }} |
| 88 | > |
| 89 | {renderVisibleWhitespace(props.fallback || " ")} |
| 90 | </NativeText> |
| 91 | ); |
| 92 | } |
| 93 | |
| 94 | return ( |
| 95 | <NativeText |
| 96 | numberOfLines={1} |
| 97 | selectable |
| 98 | className={cn("font-normal text-foreground", props.className)} |
| 99 | style={{ |
| 100 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| 101 | fontSize, |
| 102 | lineHeight, |
| 103 | }} |
| 104 | > |
| 105 | {(() => { |
| 106 | let offset = 0; |
| 107 | |
| 108 | return props.tokens.map((token) => { |
| 109 | const start = offset; |
| 110 | offset += token.content.length; |
| 111 | |
| 112 | const fontWeight = |
| 113 | token.fontStyle !== null && (token.fontStyle & 2) === 2 |
| 114 | ? ("700" as const) |
| 115 | : ("500" as const); |
| 116 | const fontStyle = |
| 117 | token.fontStyle !== null && (token.fontStyle & 1) === 1 |
| 118 | ? ("italic" as const) |
| 119 | : ("normal" as const); |
| 120 | |
| 121 | return ( |
| 122 | <NativeText |
| 123 | key={`${start}:${token.content.length}:${token.color ?? ""}:${token.fontStyle ?? ""}`} |
| 124 | selectable |
| 125 | style={{ |
| 126 | color: token.color ?? undefined, |
| 127 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| 128 | fontWeight, |
| 129 | fontStyle, |
| 130 | backgroundColor: |
| 131 | token.diffHighlight && props.change |
| 132 | ? diffHighlightColor(props.change) |
| 133 | : undefined, |
| 134 | borderRadius: token.diffHighlight ? 4 : undefined, |
| 135 | }} |
| 136 | > |
| 137 | {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "} |
| 138 | </NativeText> |
| 139 | ); |
| 140 | }); |
| 141 | })()} |
| 142 | </NativeText> |
| 143 | ); |
| 144 | } |