- Add syntax-highlighted diff rendering and cached review state - Support pasted/attached images in review comments - Improve mobile review sheet handling for large or partial diffs
Unified · Split
| @@ -11,7 +11,7 @@ · 10 unmodified lines | ||
| 11 | 11 | "**/routeTree.gen.ts", |
| 12 | 12 | "apps/web/public/mockServiceWorker.js", |
| 13 | 13 | "apps/web/src/lib/vendor/qrcodegen.ts", |
| 14 | − "apps/mobile/uniwind-types.d.ts" | |
| 14 | + "apps/mobile/uniwind-types.d.ts", | |
| 15 | 15 | "*.icon/**" |
| 16 | 16 | ], |
| 17 | 17 | "sortPackageJson": {} |
| @@ -73,6 +73,7 @@ · 72 unmodified lines | ||
| 73 | 73 | "react-native-reanimated": "4.2.1", |
| 74 | 74 | "react-native-safe-area-context": "~5.6.2", |
| 75 | 75 | "react-native-screens": "~4.23.0", |
| 76 | + "react-native-shiki-engine": "^0.3.9", | |
| 76 | 77 | "react-native-svg": "15.15.3", |
| 77 | 78 | "react-native-worklets": "0.7.2", |
| 78 | 79 | "shiki": "^4.0.2", |
| @@ -46,6 +46,7 @@ export default function ThreadLayout() { · 45 unmodified lines | ||
| 46 | 46 | options={{ |
| 47 | 47 | animation: "slide_from_right", |
| 48 | 48 | contentStyle: sheetStyle, |
| 49 | + headerBackButtonDisplayMode: "minimal", | |
| 49 | 50 | headerShown: true, |
| 50 | 51 | headerTitle: "Files changed", |
| 51 | 52 | headerBackTitle: "", |
| @@ -15,6 +15,8 @@ export interface ComposerAttachmentStripProps { · 14 unmodified lines | ||
| 15 | 15 | readonly imageSize?: number; |
| 16 | 16 | /** Border radius of each image thumbnail. Defaults to 16. */ |
| 17 | 17 | readonly imageBorderRadius?: number; |
| 18 | + /** Whether the remove button should sit in its own gutter instead of overlapping the image. */ | |
| 19 | + readonly removeButtonPlacement?: "overlay" | "gutter"; | |
| 18 | 20 | } |
| 19 | 21 | |
| 20 | 22 | /** |
| @@ -25,6 +27,8 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 4 unmodified lines | ||
| 25 | 27 | const subtleBg = useThemeColor("--color-subtle"); |
| 26 | 28 | const size = props.imageSize ?? 72; |
| 27 | 29 | const radius = props.imageBorderRadius ?? 16; |
| 30 | + const removeButtonPlacement = props.removeButtonPlacement ?? "overlay"; | |
| 31 | + const removeButtonGutter = removeButtonPlacement === "gutter" ? 10 : 0; | |
| 28 | 32 | |
| 29 | 33 | if (props.attachments.length === 0) { |
| 30 | 34 | return null; |
| @@ -39,7 +43,14 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 8 unmodified lines | ||
| 39 | 43 | > |
| 40 | 44 | <View style={{ flexDirection: "row", gap: 10 }}> |
| 41 | 45 | {props.attachments.map((image) => ( |
| 42 | − <View key={image.id} style={{ position: "relative" }}> | |
| 46 | + <View | |
| 47 | + key={image.id} | |
| 48 | + style={{ | |
| 49 | + position: "relative", | |
| 50 | + paddingTop: removeButtonGutter, | |
| 51 | + paddingRight: removeButtonGutter, | |
| 52 | + }} | |
| 53 | + > | |
| 43 | 54 | <Pressable |
| 44 | 55 | onPress={props.onPressImage ? () => props.onPressImage!(image.previewUri) : undefined} |
| 45 | 56 | > |
| @@ -57,8 +68,8 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 11 unmodified lines | ||
| 57 | 68 | <Pressable |
| 58 | 69 | style={{ |
| 59 | 70 | position: "absolute", |
| 60 | − top: 4, | |
| 61 | − right: 4, | |
| 71 | + top: removeButtonPlacement === "gutter" ? 0 : 4, | |
| 72 | + right: removeButtonPlacement === "gutter" ? 0 : 4, | |
| 62 | 73 | width: 22, |
| 63 | 74 | height: 22, |
| 64 | 75 | borderRadius: 11, |
| @@ -1,12 +1,29 @@ | ||
| 1 | 1 | import { useLocalSearchParams, useRouter } from "expo-router"; |
| 2 | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | −import { useMemo, useState } from "react"; | |
| 4 | −import { Platform, Pressable, ScrollView, Text as NativeText, View } from "react-native"; | |
| 3 | +import { TextInputWrapper } from "expo-paste-input"; | |
| 4 | +import { useEffect, useMemo, useState } from "react"; | |
| 5 | +import { | |
| 6 | + KeyboardAvoidingView, | |
| 7 | + Platform, | |
| 8 | + Pressable, | |
| 9 | + ScrollView, | |
| 10 | + View, | |
| 11 | + useColorScheme, | |
| 12 | + useWindowDimensions, | |
| 13 | +} from "react-native"; | |
| 5 | 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 15 | +import ImageViewing from "react-native-image-viewing"; | |
| 6 | 16 | |
| 7 | 17 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; |
| 18 | +import { ComposerAttachmentStrip } from "../../../components/ComposerAttachmentStrip"; | |
| 19 | +import { ControlPill } from "../../../components/ControlPill"; | |
| 8 | 20 | import { cn } from "../../../lib/cn"; |
| 21 | +import type { DraftComposerImageAttachment } from "../../../lib/composerImages"; | |
| 22 | +import { convertPastedImagesToAttachments, pickComposerImages } from "../../../lib/composerImages"; | |
| 9 | 23 | import { useThemeColor } from "../../../lib/useThemeColor"; |
| 24 | +import { useNativePaste } from "../../../hooks/useNativePaste"; | |
| 25 | +import { setPendingConnectionError } from "../../../state/use-remote-environment-registry"; | |
| 26 | +import { appendReviewCommentToDraft } from "../use-thread-composer-state"; | |
| 10 | 27 | import { |
| 11 | 28 | clearReviewCommentTarget, |
| 12 | 29 | formatReviewCommentContext, |
| @@ -15,28 +32,21 @@ import { · 2 unmodified lines | ||
| 15 | 32 | getSelectedReviewCommentLines, |
| 16 | 33 | useReviewCommentTarget, |
| 17 | 34 | } from "./reviewCommentSelection"; |
| 18 | −import { appendReviewCommentToDraft } from "../use-thread-composer-state"; | |
| 19 | − | |
| 20 | −const REVIEW_MONO_FONT_FAMILY = Platform.select({ | |
| 21 | − ios: "ui-monospace", | |
| 22 | − android: "monospace", | |
| 23 | − default: "monospace", | |
| 24 | −}); | |
| 25 | − | |
| 26 | −function renderVisibleWhitespace(value: string): string { | |
| 27 | − const expandedTabs = value.replace(/\t/g, " "); | |
| 28 | − return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0")); | |
| 29 | −} | |
| 35 | +import { changeTone, DiffTokenText } from "./reviewDiffRendering"; | |
| 36 | +import { | |
| 37 | + highlightReviewSelectedLines, | |
| 38 | + type ReviewDiffTheme, | |
| 39 | + type ReviewHighlightedToken, | |
| 40 | +} from "./shikiReviewHighlighter"; | |
| 30 | 41 | |
| 31 | −function changeTone(change: "context" | "add" | "delete"): string { | |
| 32 | − if (change === "add") return "bg-emerald-500/12"; | |
| 33 | − if (change === "delete") return "bg-rose-500/12"; | |
| 34 | − return "bg-card"; | |
| 35 | −} | |
| 42 | +const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5; | |
| 43 | +const REVIEW_COMMENT_LINE_HEIGHT = 26; | |
| 36 | 44 | |
| 37 | 45 | export function ReviewCommentComposerSheet() { |
| 38 | 46 | const router = useRouter(); |
| 39 | 47 | const insets = useSafeAreaInsets(); |
| 48 | + const { width } = useWindowDimensions(); | |
| 49 | + const colorScheme = useColorScheme(); | |
| 40 | 50 | const iconTint = String(useThemeColor("--color-icon")); |
| 41 | 51 | const target = useReviewCommentTarget(); |
| 42 | 52 | const { environmentId, threadId } = useLocalSearchParams<{ |
| @@ -44,6 +54,11 @@ export function ReviewCommentComposerSheet() { · 1 unmodified line | ||
| 44 | 54 | threadId: string; |
| 45 | 55 | }>(); |
| 46 | 56 | const [commentText, setCommentText] = useState(""); |
| 57 | + const [highlightedLinesById, setHighlightedLinesById] = useState< | |
| 58 | + Record<string, ReadonlyArray<ReviewHighlightedToken>> | |
| 59 | + >({}); | |
| 60 | + const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]); | |
| 61 | + const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); | |
| 47 | 62 | |
| 48 | 63 | const selectedLines = useMemo( |
| 49 | 64 | () => (target ? getSelectedReviewCommentLines(target) : []), |
| @@ -53,136 +68,252 @@ export function ReviewCommentComposerSheet() { · 3 unmodified lines | ||
| 53 | 68 | const lastLine = selectedLines[selectedLines.length - 1] ?? null; |
| 54 | 69 | const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null; |
| 55 | 70 | const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null; |
| 71 | + const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme; | |
| 56 | 72 | const canSubmit = |
| 57 | 73 | commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId; |
| 74 | + const selectionLabel = | |
| 75 | + selectedLines.length === 1 | |
| 76 | + ? firstNumber !== null | |
| 77 | + ? `Line ${firstNumber}` | |
| 78 | + : "File comment" | |
| 79 | + : firstNumber !== null && lastNumber !== null | |
| 80 | + ? `Lines ${firstNumber}-${lastNumber}` | |
| 81 | + : `${selectedLines.length} lines selected`; | |
| 82 | + const previewHeight = Math.max( | |
| 83 | + Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_COMMENT_LINE_HEIGHT, | |
| 84 | + REVIEW_COMMENT_LINE_HEIGHT, | |
| 85 | + ); | |
| 86 | + const previewViewportWidth = Math.max(width - 40, 280); | |
| 87 | + const handleNativePaste = useNativePaste((uris) => { | |
| 88 | + void (async () => { | |
| 89 | + try { | |
| 90 | + const images = await convertPastedImagesToAttachments({ | |
| 91 | + uris, | |
| 92 | + existingCount: attachments.length, | |
| 93 | + }); | |
| 94 | + if (images.length > 0) { | |
| 95 | + setAttachments((current) => [...current, ...images]); | |
| 96 | + } | |
| 97 | + } catch (error) { | |
| 98 | + console.error("[review comment] error converting pasted images", error); | |
| 99 | + } | |
| 100 | + })(); | |
| 101 | + }); | |
| 102 | + | |
| 103 | + useEffect(() => { | |
| 104 | + if (!target || selectedLines.length === 0) { | |
| 105 | + setHighlightedLinesById({}); | |
| 106 | + return; | |
| 107 | + } | |
| 108 | + | |
| 109 | + let cancelled = false; | |
| 110 | + void highlightReviewSelectedLines({ | |
| 111 | + filePath: target.filePath, | |
| 112 | + lines: selectedLines, | |
| 113 | + theme: selectedTheme, | |
| 114 | + }) | |
| 115 | + .then((next) => { | |
| 116 | + if (!cancelled) { | |
| 117 | + setHighlightedLinesById(next); | |
| 118 | + } | |
| 119 | + }) | |
| 120 | + .catch(() => { | |
| 121 | + if (!cancelled) { | |
| 122 | + setHighlightedLinesById({}); | |
| 123 | + } | |
| 124 | + }); | |
| 125 | + | |
| 126 | + return () => { | |
| 127 | + cancelled = true; | |
| 128 | + }; | |
| 129 | + }, [selectedLines, selectedTheme, target]); | |
| 130 | + | |
| 131 | + async function handlePickImages(): Promise<void> { | |
| 132 | + const result = await pickComposerImages({ existingCount: attachments.length }); | |
| 133 | + if (result.images.length > 0) { | |
| 134 | + setAttachments((current) => [...current, ...result.images]); | |
| 135 | + } | |
| 136 | + if (result.error) { | |
| 137 | + setPendingConnectionError(result.error); | |
| 138 | + } | |
| 139 | + } | |
| 58 | 140 | |
| 59 | 141 | return ( |
| 60 | − <ScrollView | |
| 61 | − keyboardShouldPersistTaps="handled" | |
| 62 | − showsVerticalScrollIndicator={false} | |
| 63 | − contentContainerStyle={{ | |
| 64 | − paddingHorizontal: 20, | |
| 65 | − paddingTop: 8, | |
| 66 | − paddingBottom: Math.max(insets.bottom, 18) + 18, | |
| 67 | − gap: 16, | |
| 68 | − }} | |
| 142 | + <KeyboardAvoidingView | |
| 143 | + behavior={Platform.OS === "ios" ? "padding" : "height"} | |
| 144 | + keyboardVerticalOffset={Platform.OS === "ios" ? 12 : 0} | |
| 145 | + style={{ flex: 1 }} | |
| 69 | 146 | > |
| 70 | − <View className="flex-row items-center justify-between py-2"> | |
| 71 | − <Pressable | |
| 72 | − className="bg-subtle h-12 w-12 items-center justify-center rounded-full" | |
| 73 | − onPress={() => { | |
| 74 | − clearReviewCommentTarget(); | |
| 75 | − router.dismiss(); | |
| 76 | − }} | |
| 77 | − > | |
| 78 | − <SymbolView name="xmark" size={18} tintColor={iconTint} type="monochrome" /> | |
| 79 | − </Pressable> | |
| 80 | − | |
| 81 | − <Text className="text-[18px] font-t3-bold text-foreground">Add Comment</Text> | |
| 82 | − | |
| 83 | − <Pressable | |
| 84 | − className={cn( | |
| 85 | − "min-h-[44px] min-w-[124px] items-center justify-center rounded-full px-5", | |
| 86 | − canSubmit ? "bg-foreground" : "border border-border bg-card", | |
| 87 | − )} | |
| 88 | − disabled={!canSubmit} | |
| 89 | − onPress={() => { | |
| 90 | − if (!target || !environmentId || !threadId || commentText.trim().length === 0) { | |
| 91 | − return; | |
| 92 | − } | |
| 93 | − | |
| 94 | − appendReviewCommentToDraft({ | |
| 95 | − environmentId, | |
| 96 | − threadId, | |
| 97 | − text: formatReviewCommentContext(target, commentText), | |
| 98 | − }); | |
| 99 | − clearReviewCommentTarget(); | |
| 100 | − router.dismiss(); | |
| 101 | − }} | |
| 102 | − style={{ opacity: canSubmit ? 1 : 0.55 }} | |
| 103 | − > | |
| 104 | − <Text | |
| 105 | − className={cn( | |
| 106 | − "text-[14px] font-t3-bold", | |
| 107 | − canSubmit ? "text-background" : "text-foreground-muted", | |
| 108 | − )} | |
| 147 | + <View | |
| 148 | + style={{ | |
| 149 | + flex: 1, | |
| 150 | + paddingHorizontal: 20, | |
| 151 | + paddingTop: 8, | |
| 152 | + paddingBottom: Math.max(insets.bottom, 18) + 18, | |
| 153 | + }} | |
| 154 | + > | |
| 155 | + <View className="flex-row items-center justify-between py-2"> | |
| 156 | + <Pressable | |
| 157 | + className="bg-subtle h-12 w-12 items-center justify-center rounded-full" | |
| 158 | + onPress={() => { | |
| 159 | + clearReviewCommentTarget(); | |
| 160 | + router.dismiss(); | |
| 161 | + }} | |
| 109 | 162 | > |
| 110 | − Comment | |
| 111 | − </Text> | |
| 112 | − </Pressable> | |
| 113 | − </View> | |
| 163 | + <SymbolView name="xmark" size={18} tintColor={iconTint} type="monochrome" /> | |
| 164 | + </Pressable> | |
| 114 | 165 | |
| 115 | − {!target ? ( | |
| 116 | − <View className="rounded-[22px] border border-border bg-card px-4 py-5"> | |
| 117 | − <Text className="text-[15px] font-t3-bold text-foreground">No selection</Text> | |
| 118 | − <Text className="mt-1 text-[13px] leading-[19px] text-foreground-muted"> | |
| 119 | − Select a diff line or range first. | |
| 120 | − </Text> | |
| 166 | + <Text className="text-[18px] font-t3-bold text-foreground">Add Comment</Text> | |
| 167 | + | |
| 168 | + <View className="h-12 w-12" /> | |
| 121 | 169 | </View> |
| 122 | − ) : ( | |
| 123 | − <> | |
| 124 | − <View className="gap-1 rounded-[22px] border border-border bg-card px-4 py-4"> | |
| 125 | − <Text className="font-mono text-[13px] leading-[18px] text-foreground"> | |
| 126 | − {target.filePath} | |
| 127 | − </Text> | |
| 128 | − <Text className="text-[12px] font-t3-medium text-foreground-muted"> | |
| 129 | − {selectedLines.length === 1 | |
| 130 | − ? firstNumber !== null | |
| 131 | − ? `Line ${firstNumber}` | |
| 132 | − : "File comment" | |
| 133 | − : firstNumber !== null && lastNumber !== null | |
| 134 | − ? `Lines ${firstNumber}-${lastNumber}` | |
| 135 | − : `${selectedLines.length} lines selected`} | |
| 170 | + | |
| 171 | + {!target ? ( | |
| 172 | + <View className="rounded-[22px] border border-border bg-card px-4 py-5"> | |
| 173 | + <Text className="text-[15px] font-t3-bold text-foreground">No selection</Text> | |
| 174 | + <Text className="mt-1 text-[13px] leading-[19px] text-foreground-muted"> | |
| 175 | + Select a diff line or range first. | |
| 136 | 176 | </Text> |
| 137 | 177 | </View> |
| 178 | + ) : ( | |
| 179 | + <View className="flex-1 gap-4"> | |
| 180 | + <View className="gap-1 px-1"> | |
| 181 | + <Text className="text-[11px] font-t3-bold uppercase text-foreground-muted"> | |
| 182 | + {selectionLabel} | |
| 183 | + </Text> | |
| 184 | + <Text | |
| 185 | + className="font-mono text-[12px] leading-[17px] text-foreground-muted" | |
| 186 | + ellipsizeMode="middle" | |
| 187 | + numberOfLines={2} | |
| 188 | + > | |
| 189 | + {target.filePath} | |
| 190 | + </Text> | |
| 191 | + </View> | |
| 138 | 192 | |
| 139 | − <View className="overflow-hidden rounded-[22px] border border-border bg-card"> | |
| 140 | − {selectedLines.map((line) => { | |
| 141 | − const lineNumber = getReviewUnifiedLineNumber(line); | |
| 142 | − | |
| 143 | − return ( | |
| 144 | − <View | |
| 145 | − key={line.id} | |
| 146 | − className={cn( | |
| 147 | − "flex-row items-start border-b border-border/60", | |
| 148 | − changeTone(line.change), | |
| 149 | − )} | |
| 193 | + <View className="overflow-hidden rounded-[22px] border border-border bg-card"> | |
| 194 | + <ScrollView | |
| 195 | + horizontal | |
| 196 | + bounces={false} | |
| 197 | + keyboardShouldPersistTaps="always" | |
| 198 | + showsHorizontalScrollIndicator={false} | |
| 199 | + > | |
| 200 | + <ScrollView | |
| 201 | + bounces={false} | |
| 202 | + scrollEnabled={selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES} | |
| 203 | + nestedScrollEnabled | |
| 204 | + keyboardShouldPersistTaps="always" | |
| 205 | + showsVerticalScrollIndicator={ | |
| 206 | + selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES | |
| 207 | + } | |
| 208 | + style={{ height: previewHeight }} | |
| 150 | 209 | > |
| 151 | − <Text className="w-9 px-1 py-2 text-right text-[11px] font-t3-medium text-foreground-muted"> | |
| 152 | − {lineNumber ?? ""} | |
| 153 | − </Text> | |
| 154 | − <Text | |
| 155 | − className="px-0.5 py-2 text-center font-mono text-[12px] text-foreground-muted" | |
| 156 | − style={{ width: 18 }} | |
| 157 | − > | |
| 158 | − {getReviewChangeMarker(line.change)} | |
| 159 | − </Text> | |
| 160 | − <NativeText | |
| 161 | − selectable | |
| 162 | − className="flex-1 px-1 py-2 text-[12px] leading-[19px] text-foreground" | |
| 163 | − style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} | |
| 164 | − > | |
| 165 | − {renderVisibleWhitespace(line.content || " ")} | |
| 166 | − </NativeText> | |
| 210 | + <View style={{ minWidth: previewViewportWidth }}> | |
| 211 | + {selectedLines.map((line) => { | |
| 212 | + const lineNumber = getReviewUnifiedLineNumber(line); | |
| 213 | + | |
| 214 | + return ( | |
| 215 | + <View | |
| 216 | + key={line.id} | |
| 217 | + className={cn("flex-row items-start", changeTone(line.change))} | |
| 218 | + style={{ height: REVIEW_COMMENT_LINE_HEIGHT }} | |
| 219 | + > | |
| 220 | + <Text className="w-9 px-1 py-1 text-right text-[11px] font-t3-medium text-foreground-muted"> | |
| 221 | + {lineNumber ?? ""} | |
| 222 | + </Text> | |
| 223 | + <Text | |
| 224 | + className="px-0.5 py-1 text-center font-mono text-[12px] text-foreground-muted" | |
| 225 | + style={{ width: 18 }} | |
| 226 | + > | |
| 227 | + {getReviewChangeMarker(line.change)} | |
| 228 | + </Text> | |
| 229 | + <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1"> | |
| 230 | + <DiffTokenText | |
| 231 | + fallback={line.content} | |
| 232 | + tokens={highlightedLinesById[line.id] ?? null} | |
| 233 | + /> | |
| 234 | + </View> | |
| 235 | + </View> | |
| 236 | + ); | |
| 237 | + })} | |
| 238 | + </View> | |
| 239 | + </ScrollView> | |
| 240 | + </ScrollView> | |
| 241 | + </View> | |
| 242 | + | |
| 243 | + <View className="gap-2"> | |
| 244 | + <Text className="text-[13px] font-t3-bold text-foreground">Comment</Text> | |
| 245 | + <View | |
| 246 | + className="overflow-hidden rounded-[20px] border border-border bg-card" | |
| 247 | + style={{ height: 172 }} | |
| 248 | + > | |
| 249 | + <View className="flex-1 px-4 pt-3.5"> | |
| 250 | + <TextInputWrapper onPaste={handleNativePaste}> | |
| 251 | + <TextInput | |
| 252 | + autoFocus | |
| 253 | + multiline | |
| 254 | + placeholder="Leave a comment..." | |
| 255 | + textAlignVertical="top" | |
| 256 | + value={commentText} | |
| 257 | + onChangeText={setCommentText} | |
| 258 | + className="flex-1 border-0 bg-transparent px-0 py-0 font-sans text-[15px]" | |
| 259 | + /> | |
| 260 | + </TextInputWrapper> | |
| 167 | 261 | </View> |
| 168 | − ); | |
| 169 | − })} | |
| 170 | − </View> | |
| 262 | + {attachments.length > 0 ? ( | |
| 263 | + <View className="px-4 pb-3 pt-2"> | |
| 264 | + <ComposerAttachmentStrip | |
| 265 | + attachments={attachments} | |
| 266 | + imageBorderRadius={16} | |
| 267 | + imageSize={60} | |
| 268 | + onPressImage={setPreviewImageUri} | |
| 269 | + removeButtonPlacement="gutter" | |
| 270 | + onRemove={(imageId) => { | |
| 271 | + setAttachments((current) => | |
| 272 | + current.filter((image) => image.id !== imageId), | |
| 273 | + ); | |
| 274 | + }} | |
| 275 | + /> | |
| 276 | + </View> | |
| 277 | + ) : null} | |
| 278 | + </View> | |
| 279 | + </View> | |
| 171 | 280 | |
| 172 | − <View className="gap-2"> | |
| 173 | − <Text className="text-[13px] font-t3-bold text-foreground">Comment</Text> | |
| 174 | − <TextInput | |
| 175 | − autoFocus | |
| 176 | − multiline | |
| 177 | − placeholder="Leave a comment..." | |
| 178 | − textAlignVertical="top" | |
| 179 | − value={commentText} | |
| 180 | − onChangeText={setCommentText} | |
| 181 | − className="min-h-[192px] rounded-[20px] px-4 py-3.5 font-sans text-[15px]" | |
| 182 | − /> | |
| 281 | + <View className="flex-row items-center gap-3 pt-1"> | |
| 282 | + <ControlPill icon="plus" onPress={() => void handlePickImages()} /> | |
| 283 | + <View className="flex-1" /> | |
| 284 | + <ControlPill | |
| 285 | + icon="arrow.up" | |
| 286 | + label="Comment" | |
| 287 | + variant="primary" | |
| 288 | + disabled={!canSubmit} | |
| 289 | + onPress={() => { | |
| 290 | + if (!target || !environmentId || !threadId || commentText.trim().length === 0) { | |
| 291 | + return; | |
| 292 | + } | |
| 293 | + | |
| 294 | + appendReviewCommentToDraft({ | |
| 295 | + environmentId, | |
| 296 | + threadId, | |
| 297 | + text: formatReviewCommentContext(target, commentText), | |
| 298 | + attachments, | |
| 299 | + }); | |
| 300 | + setAttachments([]); | |
| 301 | + clearReviewCommentTarget(); | |
| 302 | + router.dismiss(); | |
| 303 | + }} | |
| 304 | + /> | |
| 305 | + </View> | |
| 183 | 306 | </View> |
| 184 | − </> | |
| 185 | − )} | |
| 186 | − </ScrollView> | |
| 307 | + )} | |
| 308 | + </View> | |
| 309 | + <ImageViewing | |
| 310 | + images={previewImageUri ? [{ uri: previewImageUri }] : []} | |
| 311 | + imageIndex={0} | |
| 312 | + visible={previewImageUri !== null} | |
| 313 | + onRequestClose={() => setPreviewImageUri(null)} | |
| 314 | + swipeToCloseEnabled | |
| 315 | + doubleTapToZoomEnabled | |
| 316 | + /> | |
| 317 | + </KeyboardAvoidingView> | |
| 187 | 318 | ); |
| 188 | 319 | } |
| @@ -1,15 +1,11 @@ | ||
| 1 | −import { | |
| 2 | − ThreadId, | |
| 3 | − type GitReviewDiffSection, | |
| 4 | − type OrchestrationCheckpointSummary, | |
| 5 | −} from "@t3tools/contracts"; | |
| 1 | +import { ThreadId, type OrchestrationCheckpointSummary } from "@t3tools/contracts"; | |
| 2 | +import { LegendList } from "@legendapp/list/react-native"; | |
| 6 | 3 | import { useLocalSearchParams, useRouter } from "expo-router"; |
| 7 | 4 | import Stack from "expo-router/stack"; |
| 8 | 5 | import { SymbolView } from "expo-symbols"; |
| 9 | −import { type ReactElement, useCallback, useEffect, useMemo, useState } from "react"; | |
| 6 | +import { memo, type ReactElement, useCallback, useEffect, useMemo, useState } from "react"; | |
| 10 | 7 | import { |
| 11 | 8 | ActivityIndicator, |
| 12 | − Platform, | |
| 13 | 9 | Pressable, |
| 14 | 10 | ScrollView, |
| 15 | 11 | Text as NativeText, |
| @@ -25,16 +21,28 @@ import { useThemeColor } from "../../../lib/useThemeColor"; · 9 unmodified lines | ||
| 25 | 21 | import { getEnvironmentClient } from "../../../state/use-remote-environment-registry"; |
| 26 | 22 | import { useSelectedThreadDetail } from "../../../state/use-thread-detail"; |
| 27 | 23 | import { useThreadSelection } from "../../../state/use-thread-selection"; |
| 24 | +import { useThreadDraftForThread } from "../use-thread-composer-state"; | |
| 25 | +import { | |
| 26 | + getCachedReviewParsedDiff, | |
| 27 | + setReviewGitSections, | |
| 28 | + setReviewSelectedSectionId, | |
| 29 | + setReviewTurnDiff, | |
| 30 | + updateReviewExpandedFileIds, | |
| 31 | + updateReviewRevealedLargeFileIds, | |
| 32 | + useReviewCacheForThread, | |
| 33 | +} from "./reviewState"; | |
| 28 | 34 | import { |
| 29 | − buildReviewParsedDiff, | |
| 30 | 35 | getReadyReviewCheckpoints, |
| 31 | 36 | buildReviewSectionItems, |
| 32 | 37 | getDefaultReviewSectionId, |
| 38 | + getReviewFilePreviewState, | |
| 33 | 39 | getReviewSectionIdForCheckpoint, |
| 40 | + type ReviewParsedDiff, | |
| 34 | 41 | type ReviewRenderableFile, |
| 35 | 42 | type ReviewRenderableLineRow, |
| 36 | 43 | } from "./reviewModel"; |
| 37 | 44 | import { |
| 45 | + getCachedHighlightedReviewFile, | |
| 38 | 46 | highlightReviewFile, |
| 39 | 47 | type ReviewDiffTheme, |
| 40 | 48 | type ReviewHighlightedFile, |
| @@ -43,6 +51,7 @@ import { · 2 unmodified lines | ||
| 43 | 51 | import { |
| 44 | 52 | buildReviewCommentTarget, |
| 45 | 53 | clearReviewCommentTarget, |
| 54 | + countReviewCommentContexts, | |
| 46 | 55 | formatReviewSelectedRangeLabel, |
| 47 | 56 | getReviewChangeMarker, |
| 48 | 57 | getReviewUnifiedLineNumber, |
| @@ -51,6 +60,7 @@ import { · 2 unmodified lines | ||
| 51 | 60 | type ReviewCommentTarget, |
| 52 | 61 | useReviewCommentTarget, |
| 53 | 62 | } from "./reviewCommentSelection"; |
| 63 | +import { changeTone, DiffTokenText } from "./reviewDiffRendering"; | |
| 54 | 64 | |
| 55 | 65 | interface PendingCommentSelection { |
| 56 | 66 | readonly sectionTitle: string; |
| @@ -59,20 +69,16 @@ interface PendingCommentSelection { · 2 unmodified lines | ||
| 59 | 69 | readonly anchorIndex: number; |
| 60 | 70 | } |
| 61 | 71 | |
| 62 | −const REVIEW_MONO_FONT_FAMILY = Platform.select({ | |
| 63 | − ios: "ui-monospace", | |
| 64 | − android: "monospace", | |
| 65 | − default: "monospace", | |
| 66 | −}); | |
| 72 | +interface ReviewLineActionInput { | |
| 73 | + readonly sectionTitle: string; | |
| 74 | + readonly filePath: string; | |
| 75 | + readonly lines: ReadonlyArray<ReviewRenderableLineRow>; | |
| 76 | + readonly lineIndex: number; | |
| 77 | +} | |
| 78 | + | |
| 67 | 79 | const IOS_NAV_BAR_HEIGHT = 44; |
| 68 | 80 | const REVIEW_HEADER_SPACING = 32; |
| 69 | 81 | |
| 70 | −function changeTone(change: ReviewRenderableLineRow["change"]): string { | |
| 71 | − if (change === "add") return "bg-emerald-500/12"; | |
| 72 | − if (change === "delete") return "bg-rose-500/12"; | |
| 73 | − return "bg-card"; | |
| 74 | −} | |
| 75 | − | |
| 76 | 82 | function changeTypeLabel(type: ReviewRenderableFile["changeType"]): string { |
| 77 | 83 | switch (type) { |
| 78 | 84 | case "new": |
| @@ -88,7 +94,7 @@ function changeTypeLabel(type: ReviewRenderableFile["changeType"]): string { · 9 unmodified lines | ||
| 88 | 94 | } |
| 89 | 95 | } |
| 90 | 96 | |
| 91 | −function formatHeaderDiffSummary(parsedDiff: ReturnType<typeof buildReviewParsedDiff>): { | |
| 97 | +function formatHeaderDiffSummary(parsedDiff: ReviewParsedDiff): { | |
| 92 | 98 | readonly additions: string | null; |
| 93 | 99 | readonly deletions: string | null; |
| 94 | 100 | } { |
| @@ -102,84 +108,13 @@ function formatHeaderDiffSummary(parsedDiff: ReturnType<typeof buildReviewParsed · 7 unmodified lines | ||
| 102 | 108 | }; |
| 103 | 109 | } |
| 104 | 110 | |
| 105 | −function shouldAutoExpandFile(file: ReviewRenderableFile): boolean { | |
| 106 | − return file.additions + file.deletions <= 20 && file.rows.length <= 40; | |
| 107 | −} | |
| 108 | − | |
| 109 | 111 | function getDefaultExpandedFileIds( |
| 110 | 112 | files: ReadonlyArray<ReviewRenderableFile>, |
| 111 | 113 | ): ReadonlyArray<string> { |
| 112 | − const autoExpanded = files.filter(shouldAutoExpandFile).map((file) => file.id); | |
| 113 | − if (autoExpanded.length > 0) { | |
| 114 | − return autoExpanded; | |
| 115 | − } | |
| 116 | − return files.length === 1 ? [files[0]!.id] : []; | |
| 117 | −} | |
| 118 | − | |
| 119 | −function renderVisibleWhitespace(value: string): string { | |
| 120 | − const expandedTabs = value.replace(/\t/g, " "); | |
| 121 | − return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0")); | |
| 122 | −} | |
| 123 | − | |
| 124 | −function DiffTokenText(props: { | |
| 125 | − readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; | |
| 126 | − readonly fallback: string; | |
| 127 | −}) { | |
| 128 | − if (!props.tokens || props.tokens.length === 0) { | |
| 129 | − return ( | |
| 130 | − <NativeText | |
| 131 | − selectable | |
| 132 | − className="text-[12px] leading-[19px] text-foreground" | |
| 133 | − style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} | |
| 134 | − > | |
| 135 | − {renderVisibleWhitespace(props.fallback || " ")} | |
| 136 | − </NativeText> | |
| 137 | − ); | |
| 138 | − } | |
| 139 | − | |
| 140 | − return ( | |
| 141 | − <NativeText | |
| 142 | − selectable | |
| 143 | − className="text-[12px] leading-[19px] text-foreground" | |
| 144 | − style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} | |
| 145 | − > | |
| 146 | − {(() => { | |
| 147 | − let offset = 0; | |
| 148 | − | |
| 149 | − return props.tokens.map((token) => { | |
| 150 | − const start = offset; | |
| 151 | − offset += token.content.length; | |
| 152 | − | |
| 153 | − const fontWeight = | |
| 154 | − token.fontStyle !== null && (token.fontStyle & 2) === 2 | |
| 155 | − ? ("700" as const) | |
| 156 | − : ("400" as const); | |
| 157 | − const fontStyle = | |
| 158 | − token.fontStyle !== null && (token.fontStyle & 1) === 1 | |
| 159 | − ? ("italic" as const) | |
| 160 | − : ("normal" as const); | |
| 161 | − | |
| 162 | − return ( | |
| 163 | − <NativeText | |
| 164 | − key={`${start}:${token.content.length}:${token.color ?? ""}:${token.fontStyle ?? ""}`} | |
| 165 | − selectable | |
| 166 | − style={{ | |
| 167 | − color: token.color ?? undefined, | |
| 168 | − fontFamily: REVIEW_MONO_FONT_FAMILY, | |
| 169 | − fontWeight, | |
| 170 | − fontStyle, | |
| 171 | − }} | |
| 172 | − > | |
| 173 | − {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "} | |
| 174 | − </NativeText> | |
| 175 | − ); | |
| 176 | − }); | |
| 177 | − })()} | |
| 178 | − </NativeText> | |
| 179 | − ); | |
| 114 | + return files.map((file) => file.id); | |
| 180 | 115 | } |
| 181 | 116 | |
| 182 | −function ReviewLineRow(props: { | |
| 117 | +const ReviewLineRow = memo(function ReviewLineRow(props: { | |
| 183 | 118 | readonly line: ReviewRenderableLineRow; |
| 184 | 119 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; |
| 185 | 120 | readonly viewportWidth: number; |
| @@ -192,7 +127,7 @@ function ReviewLineRow(props: { · 6 unmodified lines | ||
| 192 | 127 | return ( |
| 193 | 128 | <Pressable |
| 194 | 129 | className={cn( |
| 195 | − "flex-row items-start border-b border-border/60", | |
| 130 | + "flex-row items-start", | |
| 196 | 131 | changeTone(props.line.change), |
| 197 | 132 | props.selectionState === "anchor" && "bg-sky-500/16", |
| 198 | 133 | props.selectionState === "selected" && "bg-amber-300/28", |
| @@ -210,30 +145,34 @@ function ReviewLineRow(props: { · 11 unmodified lines | ||
| 210 | 145 | onPress={props.onComment} |
| 211 | 146 | style={{ minWidth: props.viewportWidth }} |
| 212 | 147 | > |
| 213 | − <Text className="w-9 px-1 py-2 text-right text-[11px] font-t3-medium text-foreground-muted"> | |
| 148 | + <Text className="w-9 px-1 py-1 text-right text-[11px] font-t3-medium text-foreground-muted"> | |
| 214 | 149 | {lineNumber ?? ""} |
| 215 | 150 | </Text> |
| 216 | 151 | <Text |
| 217 | − className="px-0.5 py-2 text-center font-mono text-[12px] text-foreground-muted" | |
| 152 | + className="px-0.5 py-1 text-center font-mono text-[12px] text-foreground-muted" | |
| 218 | 153 | style={{ width: 18 }} |
| 219 | 154 | > |
| 220 | 155 | {getReviewChangeMarker(props.line.change)} |
| 221 | 156 | </Text> |
| 222 | − <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-2"> | |
| 157 | + <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1"> | |
| 223 | 158 | <DiffTokenText tokens={props.tokens} fallback={props.line.content} /> |
| 224 | 159 | </View> |
| 225 | 160 | </Pressable> |
| 226 | 161 | ); |
| 227 | −} | |
| 162 | +}); | |
| 228 | 163 | |
| 229 | −function ReviewFileCard(props: { | |
| 164 | +const ReviewFileCard = memo(function ReviewFileCard(props: { | |
| 230 | 165 | readonly file: ReviewRenderableFile; |
| 166 | + readonly fileId: string; | |
| 231 | 167 | readonly expanded: boolean; |
| 232 | − readonly onToggle: () => void; | |
| 168 | + readonly onToggleFile: (fileId: string) => void; | |
| 233 | 169 | }) { |
| 234 | 170 | return ( |
| 235 | 171 | <View className="border-b border-border bg-card" style={{ zIndex: 1 }}> |
| 236 | − <Pressable className="flex-row items-start gap-2 px-3 py-3" onPress={props.onToggle}> | |
| 172 | + <Pressable | |
| 173 | + className="flex-row items-start gap-2 px-3 py-3" | |
| 174 | + onPress={() => props.onToggleFile(props.fileId)} | |
| 175 | + > | |
| 237 | 176 | <View className="pt-0.5"> |
| 238 | 177 | <SymbolView |
| 239 | 178 | name={props.expanded ? "chevron.down" : "chevron.right"} |
| @@ -266,41 +205,44 @@ function ReviewFileCard(props: { · 26 unmodified lines | ||
| 266 | 205 | </Pressable> |
| 267 | 206 | </View> |
| 268 | 207 | ); |
| 269 | −} | |
| 208 | +}); | |
| 270 | 209 | |
| 271 | −function ReviewFileBody(props: { | |
| 210 | +const ReviewFileBody = memo(function ReviewFileBody(props: { | |
| 272 | 211 | readonly file: ReviewRenderableFile; |
| 273 | 212 | readonly sectionTitle: string; |
| 274 | 213 | readonly highlightedFile: ReviewHighlightedFile | null; |
| 275 | 214 | readonly viewportWidth: number; |
| 276 | 215 | readonly pendingSelection: PendingCommentSelection | null; |
| 277 | 216 | readonly selectedTarget: ReviewCommentTarget | null; |
| 278 | − readonly onPressLine: ( | |
| 279 | − line: ReviewRenderableLineRow, | |
| 280 | − availableLines: ReadonlyArray<ReviewRenderableLineRow>, | |
| 281 | − lineIndex: number, | |
| 282 | − ) => void; | |
| 283 | − readonly onStartRangeSelection: ( | |
| 284 | − line: ReviewRenderableLineRow, | |
| 285 | − availableLines: ReadonlyArray<ReviewRenderableLineRow>, | |
| 286 | − lineIndex: number, | |
| 287 | − ) => void; | |
| 217 | + readonly onPressLine: (input: ReviewLineActionInput) => void; | |
| 218 | + readonly onStartRangeSelection: (input: ReviewLineActionInput) => void; | |
| 288 | 219 | }) { |
| 289 | − const commentableLines = props.file.rows.filter( | |
| 290 | − (row): row is ReviewRenderableLineRow => row.kind === "line", | |
| 220 | + const commentableLines = useMemo( | |
| 221 | + () => props.file.rows.filter((row): row is ReviewRenderableLineRow => row.kind === "line"), | |
| 222 | + [props.file.rows], | |
| 223 | + ); | |
| 224 | + const lineIndexById = useMemo( | |
| 225 | + () => new Map(commentableLines.map((line, index) => [line.id, index])), | |
| 226 | + [commentableLines], | |
| 227 | + ); | |
| 228 | + const anchorLineId = useMemo( | |
| 229 | + () => | |
| 230 | + props.pendingSelection && | |
| 231 | + props.pendingSelection.sectionTitle === props.sectionTitle && | |
| 232 | + props.pendingSelection.filePath === props.file.path | |
| 233 | + ? (props.pendingSelection.lines[props.pendingSelection.anchorIndex]?.id ?? null) | |
| 234 | + : null, | |
| 235 | + [props.file.path, props.pendingSelection, props.sectionTitle], | |
| 236 | + ); | |
| 237 | + const selectedLineIds = useMemo( | |
| 238 | + () => | |
| 239 | + props.selectedTarget && | |
| 240 | + props.selectedTarget.sectionTitle === props.sectionTitle && | |
| 241 | + props.selectedTarget.filePath === props.file.path | |
| 242 | + ? new Set(getSelectedReviewCommentLines(props.selectedTarget).map((line) => line.id)) | |
| 243 | + : null, | |
| 244 | + [props.file.path, props.sectionTitle, props.selectedTarget], | |
| 291 | 245 | ); |
| 292 | − const anchorLineId = | |
| 293 | − props.pendingSelection && | |
| 294 | − props.pendingSelection.sectionTitle === props.sectionTitle && | |
| 295 | − props.pendingSelection.filePath === props.file.path | |
| 296 | − ? (props.pendingSelection.lines[props.pendingSelection.anchorIndex]?.id ?? null) | |
| 297 | − : null; | |
| 298 | − const selectedLineIds = | |
| 299 | − props.selectedTarget && | |
| 300 | − props.selectedTarget.sectionTitle === props.sectionTitle && | |
| 301 | − props.selectedTarget.filePath === props.file.path | |
| 302 | − ? new Set(getSelectedReviewCommentLines(props.selectedTarget).map((line) => line.id)) | |
| 303 | − : null; | |
| 304 | 246 | |
| 305 | 247 | return ( |
| 306 | 248 | <ScrollView |
| @@ -345,16 +287,20 @@ function ReviewFileBody(props: { · 38 unmodified lines | ||
| 345 | 287 | : null |
| 346 | 288 | } |
| 347 | 289 | onComment={() => { |
| 348 | − const lineIndex = commentableLines.findIndex( | |
| 349 | − (candidate) => candidate.id === row.id, | |
| 350 | − ); | |
| 351 | − props.onPressLine(row, commentableLines, lineIndex >= 0 ? lineIndex : 0); | |
| 290 | + props.onPressLine({ | |
| 291 | + sectionTitle: props.sectionTitle, | |
| 292 | + filePath: props.file.path, | |
| 293 | + lines: commentableLines, | |
| 294 | + lineIndex: lineIndexById.get(row.id) ?? 0, | |
| 295 | + }); | |
| 352 | 296 | }} |
| 353 | 297 | onStartRangeSelection={() => { |
| 354 | − const lineIndex = commentableLines.findIndex( | |
| 355 | − (candidate) => candidate.id === row.id, | |
| 356 | − ); | |
| 357 | − props.onStartRangeSelection(row, commentableLines, lineIndex >= 0 ? lineIndex : 0); | |
| 298 | + props.onStartRangeSelection({ | |
| 299 | + sectionTitle: props.sectionTitle, | |
| 300 | + filePath: props.file.path, | |
| 301 | + lines: commentableLines, | |
| 302 | + lineIndex: lineIndexById.get(row.id) ?? 0, | |
| 303 | + }); | |
| 358 | 304 | }} |
| 359 | 305 | /> |
| 360 | 306 | ); |
| @@ -362,7 +308,129 @@ function ReviewFileBody(props: { · 1 unmodified line | ||
| 362 | 308 | </View> |
| 363 | 309 | </ScrollView> |
| 364 | 310 | ); |
| 365 | −} | |
| 311 | +}); | |
| 312 | + | |
| 313 | +const ReviewFileSuppressedBody = memo(function ReviewFileSuppressedBody(props: { | |
| 314 | + readonly message: string; | |
| 315 | + readonly actionLabel?: string | null; | |
| 316 | + readonly fileId: string; | |
| 317 | + readonly onLoadDiffFile?: (fileId: string) => void; | |
| 318 | +}) { | |
| 319 | + return ( | |
| 320 | + <View className="gap-2 border-b border-border bg-card px-4 py-3"> | |
| 321 | + <Text className="text-[12px] leading-[18px] text-foreground-muted">{props.message}</Text> | |
| 322 | + {props.actionLabel && props.onLoadDiffFile ? ( | |
| 323 | + <Pressable | |
| 324 | + className="self-start rounded-full bg-subtle px-3 py-2" | |
| 325 | + onPress={() => props.onLoadDiffFile?.(props.fileId)} | |
| 326 | + > | |
| 327 | + <Text className="text-[12px] font-t3-bold text-foreground">{props.actionLabel}</Text> | |
| 328 | + </Pressable> | |
| 329 | + ) : null} | |
| 330 | + </View> | |
| 331 | + ); | |
| 332 | +}); | |
| 333 | + | |
| 334 | +const ReviewFileItem = memo(function ReviewFileItem(props: { | |
| 335 | + readonly file: ReviewRenderableFile; | |
| 336 | + readonly expanded: boolean; | |
| 337 | + readonly sectionTitle: string; | |
| 338 | + readonly selectedTheme: ReviewDiffTheme; | |
| 339 | + readonly pendingSelection: PendingCommentSelection | null; | |
| 340 | + readonly selectedTarget: ReviewCommentTarget | null; | |
| 341 | + readonly viewportWidth: number; | |
| 342 | + readonly revealedLarge: boolean; | |
| 343 | + readonly onToggleFile: (fileId: string) => void; | |
| 344 | + readonly onLoadDiffFile: (fileId: string) => void; | |
| 345 | + readonly onPressLine: (input: ReviewLineActionInput) => void; | |
| 346 | + readonly onStartRangeSelection: (input: ReviewLineActionInput) => void; | |
| 347 | +}) { | |
| 348 | + const previewState = useMemo(() => getReviewFilePreviewState(props.file), [props.file]); | |
| 349 | + const shouldRenderBody = | |
| 350 | + previewState.kind === "render" || (previewState.reason === "large" && props.revealedLarge); | |
| 351 | + const highlightCacheKey = `${props.selectedTheme}:${props.file.cacheKey}`; | |
| 352 | + const [highlightedFile, setHighlightedFile] = useState<ReviewHighlightedFile | null>(() => | |
| 353 | + getCachedHighlightedReviewFile(props.file, props.selectedTheme), | |
| 354 | + ); | |
| 355 | + | |
| 356 | + useEffect(() => { | |
| 357 | + setHighlightedFile(getCachedHighlightedReviewFile(props.file, props.selectedTheme)); | |
| 358 | + }, [highlightCacheKey, props.file, props.selectedTheme]); | |
| 359 | + | |
| 360 | + useEffect(() => { | |
| 361 | + if (!props.expanded || !shouldRenderBody) { | |
| 362 | + return; | |
| 363 | + } | |
| 364 | + | |
| 365 | + const cached = getCachedHighlightedReviewFile(props.file, props.selectedTheme); | |
| 366 | + if (cached) { | |
| 367 | + setHighlightedFile(cached); | |
| 368 | + return; | |
| 369 | + } | |
| 370 | + | |
| 371 | + let cancelled = false; | |
| 372 | + void highlightReviewFile(props.file, props.selectedTheme) | |
| 373 | + .then((result) => { | |
| 374 | + if (!cancelled) { | |
| 375 | + setHighlightedFile(result); | |
| 376 | + } | |
| 377 | + }) | |
| 378 | + .catch(() => { | |
| 379 | + if (cancelled) { | |
| 380 | + return; | |
| 381 | + } | |
| 382 | + }); | |
| 383 | + | |
| 384 | + return () => { | |
| 385 | + cancelled = true; | |
| 386 | + }; | |
| 387 | + }, [highlightCacheKey, props.expanded, props.file, props.selectedTheme, shouldRenderBody]); | |
| 388 | + | |
| 389 | + return ( | |
| 390 | + <> | |
| 391 | + <ReviewFileCard | |
| 392 | + file={props.file} | |
| 393 | + fileId={props.file.id} | |
| 394 | + expanded={props.expanded} | |
| 395 | + onToggleFile={props.onToggleFile} | |
| 396 | + /> | |
| 397 | + {props.expanded ? ( | |
| 398 | + shouldRenderBody ? ( | |
| 399 | + <ReviewFileBody | |
| 400 | + file={props.file} | |
| 401 | + sectionTitle={props.sectionTitle} | |
| 402 | + highlightedFile={highlightedFile} | |
| 403 | + pendingSelection={props.pendingSelection} | |
| 404 | + selectedTarget={props.selectedTarget} | |
| 405 | + viewportWidth={props.viewportWidth} | |
| 406 | + onPressLine={props.onPressLine} | |
| 407 | + onStartRangeSelection={props.onStartRangeSelection} | |
| 408 | + /> | |
| 409 | + ) : ( | |
| 410 | + <ReviewFileSuppressedBody | |
| 411 | + message={previewState.message} | |
| 412 | + actionLabel={previewState.actionLabel} | |
| 413 | + fileId={props.file.id} | |
| 414 | + onLoadDiffFile={previewState.actionLabel ? props.onLoadDiffFile : undefined} | |
| 415 | + /> | |
| 416 | + ) | |
| 417 | + ) : null} | |
| 418 | + </> | |
| 419 | + ); | |
| 420 | +}); | |
| 421 | + | |
| 422 | +const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { | |
| 423 | + return ( | |
| 424 | + <View className="border-b border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-900/60 dark:bg-amber-950/40"> | |
| 425 | + <Text className="text-[12px] font-t3-bold uppercase text-amber-700 dark:text-amber-300"> | |
| 426 | + Partial diff | |
| 427 | + </Text> | |
| 428 | + <Text className="text-[12px] leading-[18px] text-amber-800 dark:text-amber-200"> | |
| 429 | + {props.notice} | |
| 430 | + </Text> | |
| 431 | + </View> | |
| 432 | + ); | |
| 433 | +}); | |
| 366 | 434 | |
| 367 | 435 | function ReviewSelectionActionBar(props: { |
| 368 | 436 | readonly target: ReviewCommentTarget | null; |
| @@ -419,20 +487,13 @@ export function ReviewSheet() { · 50 unmodified lines | ||
| 419 | 487 | environmentId: string; |
| 420 | 488 | threadId: string; |
| 421 | 489 | }>(); |
| 490 | + const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); | |
| 491 | + const reviewCache = useReviewCacheForThread({ environmentId, threadId }); | |
| 422 | 492 | const { selectedThreadProject } = useThreadSelection(); |
| 423 | 493 | const selectedThread = useSelectedThreadDetail(); |
| 424 | − const [gitSections, setGitSections] = useState<ReadonlyArray<GitReviewDiffSection>>([]); | |
| 425 | − const [turnDiffById, setTurnDiffById] = useState<Record<string, string>>({}); | |
| 426 | 494 | const [loadingTurnIds, setLoadingTurnIds] = useState<Record<string, boolean>>({}); |
| 427 | − const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null); | |
| 428 | 495 | const [loadingGitDiffs, setLoadingGitDiffs] = useState(false); |
| 429 | 496 | const [error, setError] = useState<string | null>(null); |
| 430 | − const [expandedFileIdsBySection, setExpandedFileIdsBySection] = useState< | |
| 431 | − Record<string, ReadonlyArray<string> | undefined> | |
| 432 | − >({}); | |
| 433 | − const [highlightedFileByKey, setHighlightedFileByKey] = useState< | |
| 434 | − Record<string, ReviewHighlightedFile> | |
| 435 | − >({}); | |
| 436 | 497 | const [pendingCommentSelection, setPendingCommentSelection] = |
| 437 | 498 | useState<PendingCommentSelection | null>(null); |
| 438 | 499 | const activeCommentTarget = useReviewCommentTarget(); |
| @@ -456,39 +517,49 @@ export function ReviewSheet() { · 17 unmodified lines | ||
| 456 | 517 | () => |
| 457 | 518 | buildReviewSectionItems({ |
| 458 | 519 | checkpoints: readyCheckpoints, |
| 459 | − gitSections, | |
| 460 | − turnDiffById, | |
| 520 | + gitSections: reviewCache.gitSections, | |
| 521 | + turnDiffById: reviewCache.turnDiffById, | |
| 461 | 522 | loadingTurnIds, |
| 462 | 523 | }), |
| 463 | − [gitSections, loadingTurnIds, readyCheckpoints, turnDiffById], | |
| 524 | + [loadingTurnIds, readyCheckpoints, reviewCache.gitSections, reviewCache.turnDiffById], | |
| 464 | 525 | ); |
| 465 | 526 | |
| 466 | 527 | const selectedSection = |
| 467 | − reviewSections.find((section) => section.id === selectedSectionId) ?? reviewSections[0] ?? null; | |
| 528 | + reviewSections.find((section) => section.id === reviewCache.selectedSectionId) ?? | |
| 529 | + reviewSections[0] ?? | |
| 530 | + null; | |
| 468 | 531 | const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; |
| 469 | 532 | const parsedDiff = useMemo( |
| 470 | − () => buildReviewParsedDiff(selectedSection?.diff, selectedSection?.id ?? "mobile-review"), | |
| 471 | − [selectedSection?.diff, selectedSection?.id], | |
| 533 | + () => | |
| 534 | + getCachedReviewParsedDiff({ | |
| 535 | + threadKey: reviewCache.threadKey, | |
| 536 | + sectionId: selectedSection?.id ?? null, | |
| 537 | + diff: selectedSection?.diff, | |
| 538 | + }), | |
| 539 | + [reviewCache.threadKey, selectedSection?.diff, selectedSection?.id], | |
| 472 | 540 | ); |
| 473 | 541 | const headerDiffSummary = useMemo(() => formatHeaderDiffSummary(parsedDiff), [parsedDiff]); |
| 542 | + const pendingReviewCommentCount = useMemo( | |
| 543 | + () => countReviewCommentContexts(draftMessage), | |
| 544 | + [draftMessage], | |
| 545 | + ); | |
| 474 | 546 | |
| 475 | 547 | const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme; |
| 476 | 548 | const expandedFileIds = useMemo( |
| 477 | 549 | () => |
| 478 | 550 | selectedSection?.id && parsedDiff.kind === "files" |
| 479 | − ? (expandedFileIdsBySection[selectedSection.id] ?? | |
| 551 | + ? (reviewCache.expandedFileIdsBySection[selectedSection.id] ?? | |
| 480 | 552 | getDefaultExpandedFileIds(parsedDiff.files)) |
| 481 | 553 | : [], |
| 482 | − [expandedFileIdsBySection, parsedDiff, selectedSection?.id], | |
| 554 | + [parsedDiff, reviewCache.expandedFileIdsBySection, selectedSection?.id], | |
| 483 | 555 | ); |
| 484 | − const expandedFiles = useMemo( | |
| 556 | + const revealedLargeFileIds = useMemo( | |
| 485 | 557 | () => |
| 486 | − parsedDiff.kind === "files" | |
| 487 | − ? parsedDiff.files.filter((file) => expandedFileIds.includes(file.id)) | |
| 558 | + selectedSection?.id | |
| 559 | + ? (reviewCache.revealedLargeFileIdsBySection[selectedSection.id] ?? []) | |
| 488 | 560 | : [], |
| 489 | − [expandedFileIds, parsedDiff], | |
| 561 | + [reviewCache.revealedLargeFileIdsBySection, selectedSection?.id], | |
| 490 | 562 | ); |
| 491 | − | |
| 492 | 563 | const loadGitDiffs = useCallback(async () => { |
| 493 | 564 | if (!environmentId || !cwd) { |
| 494 | 565 | return; |
| @@ -504,13 +575,15 @@ export function ReviewSheet() { · 9 unmodified lines | ||
| 504 | 575 | setError(null); |
| 505 | 576 | try { |
| 506 | 577 | const result = await client.git.getReviewDiffs({ cwd }); |
| 507 | − setGitSections(result.sections); | |
| 578 | + if (reviewCache.threadKey) { | |
| 579 | + setReviewGitSections(reviewCache.threadKey, result.sections); | |
| 580 | + } | |
| 508 | 581 | } catch (cause) { |
| 509 | 582 | setError(cause instanceof Error ? cause.message : "Failed to load review diffs."); |
| 510 | 583 | } finally { |
| 511 | 584 | setLoadingGitDiffs(false); |
| 512 | 585 | } |
| 513 | − }, [cwd, environmentId]); | |
| 586 | + }, [cwd, environmentId, reviewCache.threadKey]); | |
| 514 | 587 | |
| 515 | 588 | const loadTurnDiff = useCallback( |
| 516 | 589 | async (checkpoint: OrchestrationCheckpointSummary, force = false) => { |
| @@ -519,9 +592,11 @@ export function ReviewSheet() { · 2 unmodified lines | ||
| 519 | 592 | } |
| 520 | 593 | |
| 521 | 594 | const sectionId = getReviewSectionIdForCheckpoint(checkpoint); |
| 522 | − setSelectedSectionId(sectionId); | |
| 595 | + if (reviewCache.threadKey) { | |
| 596 | + setReviewSelectedSectionId(reviewCache.threadKey, sectionId); | |
| 597 | + } | |
| 523 | 598 | |
| 524 | − if (!force && turnDiffById[sectionId] !== undefined) { | |
| 599 | + if (!force && reviewCache.turnDiffById[sectionId] !== undefined) { | |
| 525 | 600 | return; |
| 526 | 601 | } |
| 527 | 602 | |
| @@ -539,7 +614,9 @@ export function ReviewSheet() { · 11 unmodified lines | ||
| 539 | 614 | fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1), |
| 540 | 615 | toTurnCount: checkpoint.checkpointTurnCount, |
| 541 | 616 | }); |
| 542 | − setTurnDiffById((current) => ({ ...current, [sectionId]: result.diff })); | |
| 617 | + if (reviewCache.threadKey) { | |
| 618 | + setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff); | |
| 619 | + } | |
| 543 | 620 | } catch (cause) { |
| 544 | 621 | setError(cause instanceof Error ? cause.message : "Failed to load turn diff."); |
| 545 | 622 | } finally { |
| @@ -550,7 +627,7 @@ export function ReviewSheet() { · 4 unmodified lines | ||
| 550 | 627 | }); |
| 551 | 628 | } |
| 552 | 629 | }, |
| 553 | − [environmentId, threadId, turnDiffById], | |
| 630 | + [environmentId, reviewCache.threadKey, reviewCache.turnDiffById, threadId], | |
| 554 | 631 | ); |
| 555 | 632 | |
| 556 | 633 | useEffect(() => { |
| @@ -563,10 +640,14 @@ export function ReviewSheet() { · 6 unmodified lines | ||
| 563 | 640 | } |
| 564 | 641 | |
| 565 | 642 | const fallbackId = getDefaultReviewSectionId(reviewSections); |
| 566 | − if (!selectedSectionId || !reviewSections.some((section) => section.id === selectedSectionId)) { | |
| 567 | − setSelectedSectionId(fallbackId); | |
| 643 | + if ( | |
| 644 | + reviewCache.threadKey && | |
| 645 | + (!reviewCache.selectedSectionId || | |
| 646 | + !reviewSections.some((section) => section.id === reviewCache.selectedSectionId)) | |
| 647 | + ) { | |
| 648 | + setReviewSelectedSectionId(reviewCache.threadKey, fallbackId); | |
| 568 | 649 | } |
| 569 | − }, [reviewSections, selectedSectionId]); | |
| 650 | + }, [reviewCache.selectedSectionId, reviewCache.threadKey, reviewSections]); | |
| 570 | 651 | |
| 571 | 652 | useEffect(() => { |
| 572 | 653 | const latest = readyCheckpoints[0]; |
| @@ -575,12 +656,12 @@ export function ReviewSheet() { · 2 unmodified lines | ||
| 575 | 656 | } |
| 576 | 657 | |
| 577 | 658 | const latestId = getReviewSectionIdForCheckpoint(latest); |
| 578 | − if (turnDiffById[latestId] !== undefined || loadingTurnIds[latestId]) { | |
| 659 | + if (reviewCache.turnDiffById[latestId] !== undefined || loadingTurnIds[latestId]) { | |
| 579 | 660 | return; |
| 580 | 661 | } |
| 581 | 662 | |
| 582 | 663 | void loadTurnDiff(latest); |
| 583 | − }, [loadTurnDiff, loadingTurnIds, readyCheckpoints, turnDiffById]); | |
| 664 | + }, [loadTurnDiff, loadingTurnIds, readyCheckpoints, reviewCache.turnDiffById]); | |
| 584 | 665 | |
| 585 | 666 | useEffect(() => { |
| 586 | 667 | if (!selectedSection || selectedSection.kind !== "turn" || selectedSection.diff !== null) { |
| @@ -594,68 +675,41 @@ export function ReviewSheet() { · 7 unmodified lines | ||
| 594 | 675 | }, [checkpointBySectionId, loadTurnDiff, loadingTurnIds, selectedSection]); |
| 595 | 676 | |
| 596 | 677 | useEffect(() => { |
| 597 | − if (!selectedSectionId || parsedDiff.kind !== "files") { | |
| 678 | + if (!reviewCache.threadKey || !selectedSection?.id || parsedDiff.kind !== "files") { | |
| 598 | 679 | return; |
| 599 | 680 | } |
| 600 | 681 | |
| 601 | − setExpandedFileIdsBySection((current) => { | |
| 602 | − const existing = current[selectedSectionId]; | |
| 682 | + updateReviewExpandedFileIds(reviewCache.threadKey, selectedSection.id, (existing) => { | |
| 603 | 683 | if (existing !== undefined) { |
| 604 | 684 | const validIds = existing.filter((id) => parsedDiff.files.some((file) => file.id === id)); |
| 605 | 685 | if (validIds.length === existing.length) { |
| 606 | − return current; | |
| 686 | + return existing; | |
| 607 | 687 | } |
| 608 | − return { ...current, [selectedSectionId]: validIds }; | |
| 688 | + return validIds; | |
| 609 | 689 | } |
| 610 | 690 | |
| 611 | − return { | |
| 612 | − ...current, | |
| 613 | − [selectedSectionId]: getDefaultExpandedFileIds(parsedDiff.files), | |
| 614 | − }; | |
| 691 | + return getDefaultExpandedFileIds(parsedDiff.files); | |
| 615 | 692 | }); |
| 616 | − }, [parsedDiff, selectedSectionId]); | |
| 693 | + }, [parsedDiff, reviewCache.threadKey, selectedSection?.id]); | |
| 617 | 694 | |
| 618 | 695 | useEffect(() => { |
| 619 | − if (!selectedSection || expandedFiles.length === 0) { | |
| 696 | + if (!reviewCache.threadKey || !selectedSection?.id || parsedDiff.kind !== "files") { | |
| 620 | 697 | return; |
| 621 | 698 | } |
| 622 | 699 | |
| 623 | − const filesToHighlight = expandedFiles.filter((file) => { | |
| 624 | − const key = `${selectedSection.id}:${selectedTheme}:${file.cacheKey}`; | |
| 625 | − return highlightedFileByKey[key] === undefined; | |
| 626 | − }); | |
| 627 | − if (filesToHighlight.length === 0) { | |
| 628 | − return; | |
| 629 | − } | |
| 700 | + updateReviewRevealedLargeFileIds(reviewCache.threadKey, selectedSection.id, (existing) => { | |
| 701 | + if (existing === undefined) { | |
| 702 | + return undefined; | |
| 703 | + } | |
| 630 | 704 | |
| 631 | − let cancelled = false; | |
| 632 | − void Promise.all( | |
| 633 | − filesToHighlight.map( | |
| 634 | − async (file) => | |
| 635 | − [ | |
| 636 | − `${selectedSection.id}:${selectedTheme}:${file.cacheKey}`, | |
| 637 | − await highlightReviewFile(file, selectedTheme), | |
| 638 | − ] as const, | |
| 639 | − ), | |
| 640 | − ) | |
| 641 | − .then((entries) => { | |
| 642 | − if (!cancelled) { | |
| 643 | − setHighlightedFileByKey((current) => ({ | |
| 644 | − ...current, | |
| 645 | − ...Object.fromEntries(entries), | |
| 646 | − })); | |
| 647 | − } | |
| 648 | − }) | |
| 649 | − .catch(() => { | |
| 650 | − if (cancelled) { | |
| 651 | − return; | |
| 652 | − } | |
| 653 | − }); | |
| 705 | + const validIds = existing.filter((id) => parsedDiff.files.some((file) => file.id === id)); | |
| 706 | + if (validIds.length === existing.length) { | |
| 707 | + return existing; | |
| 708 | + } | |
| 654 | 709 | |
| 655 | − return () => { | |
| 656 | − cancelled = true; | |
| 657 | − }; | |
| 658 | − }, [expandedFiles, highlightedFileByKey, selectedSection, selectedTheme]); | |
| 710 | + return validIds; | |
| 711 | + }); | |
| 712 | + }, [parsedDiff, reviewCache.threadKey, selectedSection?.id]); | |
| 659 | 713 | |
| 660 | 714 | const refreshSelectedSection = useCallback(async () => { |
| 661 | 715 | if (!selectedSection) { |
| @@ -673,9 +727,100 @@ export function ReviewSheet() { · 11 unmodified lines | ||
| 673 | 727 | await loadGitDiffs(); |
| 674 | 728 | }, [checkpointBySectionId, loadGitDiffs, loadTurnDiff, selectedSection]); |
| 675 | 729 | |
| 676 | − const reviewContent = useMemo(() => { | |
| 730 | + const handleToggleExpandedFile = useCallback( | |
| 731 | + (fileId: string) => { | |
| 732 | + if (!reviewCache.threadKey || !selectedSection?.id || parsedDiff.kind !== "files") { | |
| 733 | + return; | |
| 734 | + } | |
| 735 | + | |
| 736 | + updateReviewExpandedFileIds(reviewCache.threadKey, selectedSection.id, (existing) => { | |
| 737 | + const currentIds = existing ?? getDefaultExpandedFileIds(parsedDiff.files); | |
| 738 | + return currentIds.includes(fileId) | |
| 739 | + ? currentIds.filter((id) => id !== fileId) | |
| 740 | + : [...currentIds, fileId]; | |
| 741 | + }); | |
| 742 | + }, | |
| 743 | + [parsedDiff, reviewCache.threadKey, selectedSection?.id], | |
| 744 | + ); | |
| 745 | + | |
| 746 | + const handleRevealLargeDiff = useCallback( | |
| 747 | + (fileId: string) => { | |
| 748 | + if (!reviewCache.threadKey || !selectedSection?.id) { | |
| 749 | + return; | |
| 750 | + } | |
| 751 | + | |
| 752 | + updateReviewRevealedLargeFileIds(reviewCache.threadKey, selectedSection.id, (existing) => { | |
| 753 | + const currentIds = existing ?? []; | |
| 754 | + return currentIds.includes(fileId) ? currentIds : [...currentIds, fileId]; | |
| 755 | + }); | |
| 756 | + }, | |
| 757 | + [reviewCache.threadKey, selectedSection?.id], | |
| 758 | + ); | |
| 759 | + | |
| 760 | + const handlePressLine = useCallback( | |
| 761 | + (input: ReviewLineActionInput) => { | |
| 762 | + if (pendingCommentSelection) { | |
| 763 | + if ( | |
| 764 | + pendingCommentSelection.sectionTitle === input.sectionTitle && | |
| 765 | + pendingCommentSelection.filePath === input.filePath | |
| 766 | + ) { | |
| 767 | + setReviewCommentTarget( | |
| 768 | + buildReviewCommentTarget( | |
| 769 | + { | |
| 770 | + sectionTitle: pendingCommentSelection.sectionTitle, | |
| 771 | + filePath: pendingCommentSelection.filePath, | |
| 772 | + lines: pendingCommentSelection.lines, | |
| 773 | + }, | |
| 774 | + pendingCommentSelection.anchorIndex, | |
| 775 | + input.lineIndex, | |
| 776 | + ), | |
| 777 | + ); | |
| 778 | + setPendingCommentSelection(null); | |
| 779 | + return; | |
| 780 | + } | |
| 781 | + | |
| 782 | + clearReviewCommentTarget(); | |
| 783 | + setPendingCommentSelection({ | |
| 784 | + sectionTitle: input.sectionTitle, | |
| 785 | + filePath: input.filePath, | |
| 786 | + lines: input.lines, | |
| 787 | + anchorIndex: input.lineIndex, | |
| 788 | + }); | |
| 789 | + return; | |
| 790 | + } | |
| 791 | + | |
| 792 | + setReviewCommentTarget({ | |
| 793 | + sectionTitle: input.sectionTitle, | |
| 794 | + filePath: input.filePath, | |
| 795 | + lines: input.lines, | |
| 796 | + startIndex: input.lineIndex, | |
| 797 | + endIndex: input.lineIndex, | |
| 798 | + }); | |
| 799 | + if (environmentId && threadId) { | |
| 800 | + router.push({ | |
| 801 | + pathname: "/threads/[environmentId]/[threadId]/review-comment", | |
| 802 | + params: { environmentId, threadId }, | |
| 803 | + }); | |
| 804 | + } | |
| 805 | + }, | |
| 806 | + [environmentId, pendingCommentSelection, router, threadId], | |
| 807 | + ); | |
| 808 | + | |
| 809 | + const handleStartRangeSelection = useCallback((input: ReviewLineActionInput) => { | |
| 810 | + clearReviewCommentTarget(); | |
| 811 | + setPendingCommentSelection({ | |
| 812 | + sectionTitle: input.sectionTitle, | |
| 813 | + filePath: input.filePath, | |
| 814 | + lines: input.lines, | |
| 815 | + anchorIndex: input.lineIndex, | |
| 816 | + }); | |
| 817 | + }, []); | |
| 818 | + | |
| 819 | + const parsedDiffNotice = | |
| 820 | + parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null; | |
| 821 | + | |
| 822 | + const listHeader = useMemo(() => { | |
| 677 | 823 | const children: ReactElement[] = []; |
| 678 | − const stickyHeaderIndices: number[] = []; | |
| 679 | 824 | |
| 680 | 825 | if (error) { |
| 681 | 826 | children.push( |
| @@ -686,185 +831,85 @@ export function ReviewSheet() { · 4 unmodified lines | ||
| 686 | 831 | ); |
| 687 | 832 | } |
| 688 | 833 | |
| 689 | − if (parsedDiff.kind !== "empty" && parsedDiff.notice) { | |
| 690 | − children.push( | |
| 691 | − <View | |
| 692 | − key="review-notice" | |
| 693 | − className="border-b border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-900/60 dark:bg-amber-950/40" | |
| 694 | − > | |
| 695 | − <Text className="text-[12px] font-t3-bold uppercase text-amber-700 dark:text-amber-300"> | |
| 696 | − Partial diff | |
| 697 | − </Text> | |
| 698 | − <Text className="text-[12px] leading-[18px] text-amber-800 dark:text-amber-200"> | |
| 699 | − {parsedDiff.notice} | |
| 700 | − </Text> | |
| 701 | − </View>, | |
| 702 | − ); | |
| 703 | − } | |
| 704 | − | |
| 705 | − if (!selectedSection) { | |
| 706 | − children.push( | |
| 707 | − <View key="review-empty-state" className="border-b border-border bg-card px-4 py-5"> | |
| 708 | − <Text className="text-[14px] font-t3-bold text-foreground">No review diffs</Text> | |
| 709 | − <Text className="text-[12px] leading-[18px] text-foreground-muted"> | |
| 710 | − This thread has no ready turn diffs and the worktree diff is empty. | |
| 711 | − </Text> | |
| 712 | − </View>, | |
| 713 | − ); | |
| 714 | − return { children, stickyHeaderIndices }; | |
| 834 | + if (parsedDiffNotice) { | |
| 835 | + children.push(<ReviewNotice key="review-notice" notice={parsedDiffNotice} />); | |
| 715 | 836 | } |
| 716 | 837 | |
| 717 | − if (selectedSection.isLoading && selectedSection.diff === null) { | |
| 718 | − children.push( | |
| 719 | − <View | |
| 720 | − key="review-loading" | |
| 721 | − className="items-center gap-3 border-b border-border bg-card px-4 py-6" | |
| 722 | − > | |
| 723 | − <ActivityIndicator size="small" /> | |
| 724 | − <Text className="text-[12px] text-foreground-muted">Loading diff…</Text> | |
| 725 | − </View>, | |
| 726 | − ); | |
| 727 | − return { children, stickyHeaderIndices }; | |
| 838 | + if (children.length === 0) { | |
| 839 | + return null; | |
| 728 | 840 | } |
| 729 | 841 | |
| 730 | − if (parsedDiff.kind === "empty") { | |
| 731 | − children.push( | |
| 732 | − <View key="review-no-changes" className="border-b border-border bg-card px-4 py-5"> | |
| 733 | − <Text className="text-[14px] font-t3-bold text-foreground">No changes</Text> | |
| 734 | − <Text className="text-[12px] leading-[18px] text-foreground-muted"> | |
| 735 | − {selectedSection.subtitle ?? "This diff is empty."} | |
| 736 | − </Text> | |
| 737 | − </View>, | |
| 738 | − ); | |
| 739 | − return { children, stickyHeaderIndices }; | |
| 740 | − } | |
| 842 | + return <>{children}</>; | |
| 843 | + }, [error, parsedDiffNotice]); | |
| 741 | 844 | |
| 742 | − if (parsedDiff.kind === "raw") { | |
| 743 | − children.push( | |
| 744 | − <View key="review-raw" className="gap-3 border-b border-border bg-card px-4 py-4"> | |
| 745 | − <Text className="text-[12px] leading-[18px] text-foreground-muted"> | |
| 746 | − {parsedDiff.reason} | |
| 747 | − </Text> | |
| 748 | − <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}> | |
| 749 | − <Text selectable className="font-mono text-[12px] leading-[19px] text-foreground"> | |
| 750 | − {parsedDiff.text} | |
| 751 | − </Text> | |
| 752 | − </ScrollView> | |
| 753 | − </View>, | |
| 754 | − ); | |
| 755 | − return { children, stickyHeaderIndices }; | |
| 756 | − } | |
| 757 | − | |
| 758 | − parsedDiff.files.forEach((file) => { | |
| 759 | − const isExpanded = expandedFileIds.includes(file.id); | |
| 760 | − const fileHighlightKey = `${selectedSection.id}:${selectedTheme}:${file.cacheKey}`; | |
| 761 | − const fileHighlights = highlightedFileByKey[fileHighlightKey] ?? null; | |
| 762 | − | |
| 763 | − stickyHeaderIndices.push(children.length); | |
| 764 | − children.push( | |
| 765 | − <ReviewFileCard | |
| 766 | − key={`review-file-header:${file.id}`} | |
| 767 | − file={file} | |
| 768 | − expanded={isExpanded} | |
| 769 | − onToggle={() => | |
| 770 | − setExpandedFileIdsBySection((current) => { | |
| 771 | − const existing = | |
| 772 | − current[selectedSection.id] ?? getDefaultExpandedFileIds(parsedDiff.files); | |
| 773 | − const next = existing.includes(file.id) | |
| 774 | − ? existing.filter((id) => id !== file.id) | |
| 775 | − : [...existing, file.id]; | |
| 776 | − return { | |
| 777 | − ...current, | |
| 778 | − [selectedSection.id]: next, | |
| 779 | − }; | |
| 780 | − }) | |
| 781 | − } | |
| 782 | − />, | |
| 783 | − ); | |
| 784 | − | |
| 785 | − if (isExpanded) { | |
| 786 | − children.push( | |
| 787 | − <ReviewFileBody | |
| 788 | − key={`review-file-body:${file.id}`} | |
| 789 | − file={file} | |
| 790 | − sectionTitle={selectedSection.title} | |
| 791 | − highlightedFile={fileHighlights} | |
| 792 | − pendingSelection={pendingCommentSelection} | |
| 793 | − selectedTarget={activeCommentTarget} | |
| 794 | − viewportWidth={Math.max(width, 280)} | |
| 795 | − onPressLine={(_line, availableLines, lineIndex) => { | |
| 796 | − if (pendingCommentSelection) { | |
| 797 | − if ( | |
| 798 | − pendingCommentSelection.sectionTitle === selectedSection.title && | |
| 799 | − pendingCommentSelection.filePath === file.path | |
| 800 | − ) { | |
| 801 | − setReviewCommentTarget( | |
| 802 | − buildReviewCommentTarget( | |
| 803 | − { | |
| 804 | − sectionTitle: pendingCommentSelection.sectionTitle, | |
| 805 | − filePath: pendingCommentSelection.filePath, | |
| 806 | − lines: pendingCommentSelection.lines, | |
| 807 | − }, | |
| 808 | − pendingCommentSelection.anchorIndex, | |
| 809 | − lineIndex, | |
| 810 | − ), | |
| 811 | − ); | |
| 812 | − setPendingCommentSelection(null); | |
| 813 | − return; | |
| 814 | − } | |
| 815 | − | |
| 816 | − clearReviewCommentTarget(); | |
| 817 | − setPendingCommentSelection({ | |
| 818 | − sectionTitle: selectedSection.title, | |
| 819 | − filePath: file.path, | |
| 820 | − lines: availableLines, | |
| 821 | − anchorIndex: lineIndex, | |
| 822 | − }); | |
| 823 | − return; | |
| 824 | − } | |
| 825 | − | |
| 826 | − setReviewCommentTarget({ | |
| 827 | − sectionTitle: selectedSection.title, | |
| 828 | − filePath: file.path, | |
| 829 | − lines: availableLines, | |
| 830 | − startIndex: lineIndex, | |
| 831 | − endIndex: lineIndex, | |
| 832 | − }); | |
| 833 | − if (environmentId && threadId) { | |
| 834 | − router.push({ | |
| 835 | − pathname: "/threads/[environmentId]/[threadId]/review-comment", | |
| 836 | − params: { environmentId, threadId }, | |
| 837 | − }); | |
| 838 | − } | |
| 839 | − }} | |
| 840 | − onStartRangeSelection={(_line, availableLines, lineIndex) => { | |
| 841 | − clearReviewCommentTarget(); | |
| 842 | − setPendingCommentSelection({ | |
| 843 | − sectionTitle: selectedSection.title, | |
| 844 | − filePath: file.path, | |
| 845 | − lines: availableLines, | |
| 846 | − anchorIndex: lineIndex, | |
| 847 | − }); | |
| 848 | − }} | |
| 849 | − />, | |
| 850 | − ); | |
| 845 | + const renderFileItem = useCallback( | |
| 846 | + ({ item }: { item: ReviewRenderableFile; index: number }) => { | |
| 847 | + if (!selectedSection) { | |
| 848 | + return null; | |
| 851 | 849 | } |
| 852 | − }); | |
| 850 | + const pendingSelectionForFile = | |
| 851 | + pendingCommentSelection && | |
| 852 | + pendingCommentSelection.sectionTitle === selectedSection.title && | |
| 853 | + pendingCommentSelection.filePath === item.path | |
| 854 | + ? pendingCommentSelection | |
| 855 | + : null; | |
| 856 | + const selectedTargetForFile = | |
| 857 | + activeCommentTarget && | |
| 858 | + activeCommentTarget.sectionTitle === selectedSection.title && | |
| 859 | + activeCommentTarget.filePath === item.path | |
| 860 | + ? activeCommentTarget | |
| 861 | + : null; | |
| 862 | + | |
| 863 | + return ( | |
| 864 | + <ReviewFileItem | |
| 865 | + file={item} | |
| 866 | + expanded={expandedFileIds.includes(item.id)} | |
| 867 | + sectionTitle={selectedSection.title} | |
| 868 | + selectedTheme={selectedTheme} | |
| 869 | + pendingSelection={pendingSelectionForFile} | |
| 870 | + selectedTarget={selectedTargetForFile} | |
| 871 | + viewportWidth={Math.max(width, 280)} | |
| 872 | + revealedLarge={revealedLargeFileIds.includes(item.id)} | |
| 873 | + onToggleFile={handleToggleExpandedFile} | |
| 874 | + onLoadDiffFile={handleRevealLargeDiff} | |
| 875 | + onPressLine={handlePressLine} | |
| 876 | + onStartRangeSelection={handleStartRangeSelection} | |
| 877 | + /> | |
| 878 | + ); | |
| 879 | + }, | |
| 880 | + [ | |
| 881 | + activeCommentTarget, | |
| 882 | + expandedFileIds, | |
| 883 | + handlePressLine, | |
| 884 | + handleRevealLargeDiff, | |
| 885 | + handleStartRangeSelection, | |
| 886 | + handleToggleExpandedFile, | |
| 887 | + pendingCommentSelection, | |
| 888 | + revealedLargeFileIds, | |
| 889 | + selectedSection, | |
| 890 | + selectedTheme, | |
| 891 | + width, | |
| 892 | + ], | |
| 893 | + ); | |
| 853 | 894 | |
| 854 | − return { children, stickyHeaderIndices }; | |
| 895 | + const visibleListExtraData = useMemo(() => { | |
| 896 | + return { | |
| 897 | + selectedSectionId: selectedSection?.id ?? null, | |
| 898 | + selectedTheme, | |
| 899 | + expandedFileIds, | |
| 900 | + revealedLargeFileIds, | |
| 901 | + pendingSelection: pendingCommentSelection, | |
| 902 | + selectedTarget: activeCommentTarget, | |
| 903 | + viewportWidth: Math.max(width, 280), | |
| 904 | + }; | |
| 855 | 905 | }, [ |
| 856 | 906 | activeCommentTarget, |
| 857 | − error, | |
| 858 | 907 | expandedFileIds, |
| 859 | − highlightedFileByKey, | |
| 860 | 908 | pendingCommentSelection, |
| 861 | − parsedDiff, | |
| 862 | − router, | |
| 909 | + revealedLargeFileIds, | |
| 863 | 910 | selectedSection, |
| 864 | 911 | selectedTheme, |
| 865 | − threadId, | |
| 866 | 912 | width, |
| 867 | − environmentId, | |
| 868 | 913 | ]); |
| 869 | 914 | |
| 870 | 915 | return ( |
| @@ -891,7 +936,15 @@ export function ReviewSheet() { · 20 unmodified lines | ||
| 891 | 936 | > |
| 892 | 937 | Files Changed |
| 893 | 938 | </NativeText> |
| 894 | − <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}> | |
| 939 | + <View | |
| 940 | + style={{ | |
| 941 | + flexDirection: "row", | |
| 942 | + alignItems: "center", | |
| 943 | + justifyContent: "center", | |
| 944 | + gap: 6, | |
| 945 | + flexWrap: "wrap", | |
| 946 | + }} | |
| 947 | + > | |
| 895 | 948 | {headerDiffSummary.additions && headerDiffSummary.deletions ? ( |
| 896 | 949 | <> |
| 897 | 950 | <NativeText |
| @@ -914,19 +967,45 @@ export function ReviewSheet() { · 16 unmodified lines | ||
| 914 | 967 | > |
| 915 | 968 | {headerDiffSummary.deletions} |
| 916 | 969 | </NativeText> |
| 970 | + {pendingReviewCommentCount > 0 ? ( | |
| 971 | + <NativeText | |
| 972 | + style={{ | |
| 973 | + fontFamily: "DMSans_700Bold", | |
| 974 | + fontSize: 12, | |
| 975 | + fontWeight: "700", | |
| 976 | + color: "#b45309", | |
| 977 | + }} | |
| 978 | + > | |
| 979 | + {pendingReviewCommentCount} pending | |
| 980 | + </NativeText> | |
| 981 | + ) : null} | |
| 917 | 982 | </> |
| 918 | 983 | ) : ( |
| 919 | − <NativeText | |
| 920 | − numberOfLines={1} | |
| 921 | − style={{ | |
| 922 | − fontFamily: "DMSans_700Bold", | |
| 923 | − fontSize: 12, | |
| 924 | − fontWeight: "700", | |
| 925 | − color: headerMuted, | |
| 926 | − }} | |
| 927 | − > | |
| 928 | − {selectedSection?.title ?? "Review changes"} | |
| 929 | − </NativeText> | |
| 984 | + <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}> | |
| 985 | + <NativeText | |
| 986 | + numberOfLines={1} | |
| 987 | + style={{ | |
| 988 | + fontFamily: "DMSans_700Bold", | |
| 989 | + fontSize: 12, | |
| 990 | + fontWeight: "700", | |
| 991 | + color: headerMuted, | |
| 992 | + }} | |
| 993 | + > | |
| 994 | + {selectedSection?.title ?? "Review changes"} | |
| 995 | + </NativeText> | |
| 996 | + {pendingReviewCommentCount > 0 ? ( | |
| 997 | + <NativeText | |
| 998 | + style={{ | |
| 999 | + fontFamily: "DMSans_700Bold", | |
| 1000 | + fontSize: 12, | |
| 1001 | + fontWeight: "700", | |
| 1002 | + color: "#b45309", | |
| 1003 | + }} | |
| 1004 | + > | |
| 1005 | + {pendingReviewCommentCount} pending | |
| 1006 | + </NativeText> | |
| 1007 | + ) : null} | |
| 1008 | + </View> | |
| 930 | 1009 | )} |
| 931 | 1010 | </View> |
| 932 | 1011 | </View> |
| @@ -940,7 +1019,11 @@ export function ReviewSheet() { · 7 unmodified lines | ||
| 940 | 1019 | <Stack.Toolbar.MenuAction |
| 941 | 1020 | key={section.id} |
| 942 | 1021 | icon={section.id === selectedSection?.id ? "checkmark" : "circle"} |
| 943 | − onPress={() => setSelectedSectionId(section.id)} | |
| 1022 | + onPress={() => { | |
| 1023 | + if (reviewCache.threadKey) { | |
| 1024 | + setReviewSelectedSectionId(reviewCache.threadKey, section.id); | |
| 1025 | + } | |
| 1026 | + }} | |
| 944 | 1027 | subtitle={section.subtitle ?? undefined} |
| 945 | 1028 | > |
| 946 | 1029 | <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label> |
| @@ -961,22 +1044,73 @@ export function ReviewSheet() { · 14 unmodified lines | ||
| 961 | 1044 | </Stack.Toolbar> |
| 962 | 1045 | |
| 963 | 1046 | <View className="flex-1 bg-sheet"> |
| 964 | − <ScrollView | |
| 965 | − contentInsetAdjustmentBehavior="never" | |
| 966 | − contentInset={{ top: topContentInset }} | |
| 967 | − contentOffset={{ x: 0, y: -topContentInset }} | |
| 968 | − scrollIndicatorInsets={{ top: topContentInset }} | |
| 969 | − showsVerticalScrollIndicator={false} | |
| 970 | − style={{ flex: 1 }} | |
| 971 | − stickyHeaderIndices={reviewContent.stickyHeaderIndices} | |
| 972 | − stickyHeaderHiddenOnScroll={false} | |
| 973 | − contentContainerStyle={{ | |
| 974 | − paddingTop: REVIEW_HEADER_SPACING, | |
| 975 | − paddingBottom: Math.max(insets.bottom, 18) + 18, | |
| 976 | − }} | |
| 977 | − > | |
| 978 | − {reviewContent.children} | |
| 979 | − </ScrollView> | |
| 1047 | + {selectedSection && parsedDiff.kind === "files" ? ( | |
| 1048 | + <LegendList | |
| 1049 | + style={{ flex: 1 }} | |
| 1050 | + contentInsetAdjustmentBehavior="never" | |
| 1051 | + contentInset={{ top: topContentInset }} | |
| 1052 | + scrollIndicatorInsets={{ top: topContentInset }} | |
| 1053 | + data={parsedDiff.files as ReviewRenderableFile[]} | |
| 1054 | + renderItem={renderFileItem} | |
| 1055 | + keyExtractor={(file) => file.id} | |
| 1056 | + extraData={visibleListExtraData} | |
| 1057 | + keyboardShouldPersistTaps="handled" | |
| 1058 | + estimatedItemSize={220} | |
| 1059 | + drawDistance={900} | |
| 1060 | + recycleItems | |
| 1061 | + ListHeaderComponent={listHeader} | |
| 1062 | + contentContainerStyle={{ | |
| 1063 | + paddingTop: REVIEW_HEADER_SPACING, | |
| 1064 | + paddingBottom: Math.max(insets.bottom, 18) + 18, | |
| 1065 | + }} | |
| 1066 | + /> | |
| 1067 | + ) : ( | |
| 1068 | + <ScrollView | |
| 1069 | + contentInsetAdjustmentBehavior="never" | |
| 1070 | + contentInset={{ top: topContentInset }} | |
| 1071 | + contentOffset={{ x: 0, y: -topContentInset }} | |
| 1072 | + scrollIndicatorInsets={{ top: topContentInset }} | |
| 1073 | + showsVerticalScrollIndicator={false} | |
| 1074 | + style={{ flex: 1 }} | |
| 1075 | + contentContainerStyle={{ | |
| 1076 | + paddingTop: REVIEW_HEADER_SPACING, | |
| 1077 | + paddingBottom: Math.max(insets.bottom, 18) + 18, | |
| 1078 | + }} | |
| 1079 | + > | |
| 1080 | + {listHeader} | |
| 1081 | + {!selectedSection ? ( | |
| 1082 | + <View className="border-b border-border bg-card px-4 py-5"> | |
| 1083 | + <Text className="text-[14px] font-t3-bold text-foreground">No review diffs</Text> | |
| 1084 | + <Text className="text-[12px] leading-[18px] text-foreground-muted"> | |
| 1085 | + This thread has no ready turn diffs and the worktree diff is empty. | |
| 1086 | + </Text> | |
| 1087 | + </View> | |
| 1088 | + ) : selectedSection.isLoading && selectedSection.diff === null ? ( | |
| 1089 | + <View className="items-center gap-3 border-b border-border bg-card px-4 py-6"> | |
| 1090 | + <ActivityIndicator size="small" /> | |
| 1091 | + <Text className="text-[12px] text-foreground-muted">Loading diff…</Text> | |
| 1092 | + </View> | |
| 1093 | + ) : parsedDiff.kind === "empty" ? ( | |
| 1094 | + <View className="border-b border-border bg-card px-4 py-5"> | |
| 1095 | + <Text className="text-[14px] font-t3-bold text-foreground">No changes</Text> | |
| 1096 | + <Text className="text-[12px] leading-[18px] text-foreground-muted"> | |
| 1097 | + {selectedSection.subtitle ?? "This diff is empty."} | |
| 1098 | + </Text> | |
| 1099 | + </View> | |
| 1100 | + ) : parsedDiff.kind === "raw" ? ( | |
| 1101 | + <View className="gap-3 border-b border-border bg-card px-4 py-4"> | |
| 1102 | + <Text className="text-[12px] leading-[18px] text-foreground-muted"> | |
| 1103 | + {parsedDiff.reason} | |
| 1104 | + </Text> | |
| 1105 | + <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}> | |
| 1106 | + <Text selectable className="font-mono text-[12px] leading-[19px] text-foreground"> | |
| 1107 | + {parsedDiff.text} | |
| 1108 | + </Text> | |
| 1109 | + </ScrollView> | |
| 1110 | + </View> | |
| 1111 | + ) : null} | |
| 1112 | + </ScrollView> | |
| 1113 | + )} | |
| 980 | 1114 | |
| 981 | 1115 | <ReviewSelectionActionBar |
| 982 | 1116 | target={activeCommentTarget} |
| @@ -152,3 +152,7 @@ export function formatReviewCommentContext(target: ReviewCommentTarget, comment: · 151 unmodified lines | ||
| 152 | 152 | "</review_comment>", |
| 153 | 153 | ].join("\n"); |
| 154 | 154 | } |
| 155 | + | |
| 156 | +export function countReviewCommentContexts(value: string): number { | |
| 157 | + return Array.from(value.matchAll(/<review_comment>/g)).length; | |
| 158 | +} | |
| @@ -0,0 +1,82 @@ | ||
| 1 | +import { Platform, Text as NativeText } from "react-native"; | |
| 2 | + | |
| 3 | +import { cn } from "../../../lib/cn"; | |
| 4 | + | |
| 5 | +import type { ReviewRenderableLineRow } from "./reviewModel"; | |
| 6 | +import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; | |
| 7 | + | |
| 8 | +export const REVIEW_MONO_FONT_FAMILY = Platform.select({ | |
| 9 | + ios: "ui-monospace", | |
| 10 | + android: "monospace", | |
| 11 | + default: "monospace", | |
| 12 | +}); | |
| 13 | + | |
| 14 | +export function renderVisibleWhitespace(value: string): string { | |
| 15 | + const expandedTabs = value.replace(/\t/g, " "); | |
| 16 | + return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0")); | |
| 17 | +} | |
| 18 | + | |
| 19 | +export function changeTone(change: ReviewRenderableLineRow["change"]): string { | |
| 20 | + if (change === "add") return "bg-emerald-500/12"; | |
| 21 | + if (change === "delete") return "bg-rose-500/12"; | |
| 22 | + return "bg-card"; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export function DiffTokenText(props: { | |
| 26 | + readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; | |
| 27 | + readonly fallback: string; | |
| 28 | + readonly className?: string; | |
| 29 | +}) { | |
| 30 | + if (!props.tokens || props.tokens.length === 0) { | |
| 31 | + return ( | |
| 32 | + <NativeText | |
| 33 | + selectable | |
| 34 | + className={cn("text-[13px] leading-[17px] font-medium text-foreground", props.className)} | |
| 35 | + style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} | |
| 36 | + > | |
| 37 | + {renderVisibleWhitespace(props.fallback || " ")} | |
| 38 | + </NativeText> | |
| 39 | + ); | |
| 40 | + } | |
| 41 | + | |
| 42 | + return ( | |
| 43 | + <NativeText | |
| 44 | + selectable | |
| 45 | + className={cn("text-[13px] leading-[17px] font-medium text-foreground", props.className)} | |
| 46 | + style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} | |
| 47 | + > | |
| 48 | + {(() => { | |
| 49 | + let offset = 0; | |
| 50 | + | |
| 51 | + return props.tokens.map((token) => { | |
| 52 | + const start = offset; | |
| 53 | + offset += token.content.length; | |
| 54 | + | |
| 55 | + const fontWeight = | |
| 56 | + token.fontStyle !== null && (token.fontStyle & 2) === 2 | |
| 57 | + ? ("700" as const) | |
| 58 | + : ("500" as const); | |
| 59 | + const fontStyle = | |
| 60 | + token.fontStyle !== null && (token.fontStyle & 1) === 1 | |
| 61 | + ? ("italic" as const) | |
| 62 | + : ("normal" as const); | |
| 63 | + | |
| 64 | + return ( | |
| 65 | + <NativeText | |
| 66 | + key={`${start}:${token.content.length}:${token.color ?? ""}:${token.fontStyle ?? ""}`} | |
| 67 | + selectable | |
| 68 | + style={{ | |
| 69 | + color: token.color ?? undefined, | |
| 70 | + fontFamily: REVIEW_MONO_FONT_FAMILY, | |
| 71 | + fontWeight, | |
| 72 | + fontStyle, | |
| 73 | + }} | |
| 74 | + > | |
| 75 | + {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "} | |
| 76 | + </NativeText> | |
| 77 | + ); | |
| 78 | + }); | |
| 79 | + })()} | |
| 80 | + </NativeText> | |
| 81 | + ); | |
| 82 | +} | |
| @@ -0,0 +1,43 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + hasTurboModuleProxy, | |
| 5 | + resolveReviewHighlighterEngine, | |
| 6 | + resolveReviewHighlighterEnginePreference, | |
| 7 | +} from "./reviewHighlighterEngine"; | |
| 8 | + | |
| 9 | +describe("resolveReviewHighlighterEnginePreference", () => { | |
| 10 | + it("defaults invalid values to auto", () => { | |
| 11 | + expect(resolveReviewHighlighterEnginePreference(undefined)).toBe("auto"); | |
| 12 | + expect(resolveReviewHighlighterEnginePreference("bogus")).toBe("auto"); | |
| 13 | + }); | |
| 14 | + | |
| 15 | + it("accepts supported values", () => { | |
| 16 | + expect(resolveReviewHighlighterEnginePreference("auto")).toBe("auto"); | |
| 17 | + expect(resolveReviewHighlighterEnginePreference("javascript")).toBe("javascript"); | |
| 18 | + expect(resolveReviewHighlighterEnginePreference("native")).toBe("native"); | |
| 19 | + }); | |
| 20 | +}); | |
| 21 | + | |
| 22 | +describe("resolveReviewHighlighterEngine", () => { | |
| 23 | + it("uses javascript when explicitly requested", () => { | |
| 24 | + expect(resolveReviewHighlighterEngine("javascript", true)).toBe("javascript"); | |
| 25 | + expect(resolveReviewHighlighterEngine("javascript", false)).toBe("javascript"); | |
| 26 | + }); | |
| 27 | + | |
| 28 | + it("uses native when available for auto or native preference", () => { | |
| 29 | + expect(resolveReviewHighlighterEngine("auto", true)).toBe("native"); | |
| 30 | + expect(resolveReviewHighlighterEngine("native", true)).toBe("native"); | |
| 31 | + }); | |
| 32 | + | |
| 33 | + it("falls back to javascript when native is unavailable", () => { | |
| 34 | + expect(resolveReviewHighlighterEngine("auto", false)).toBe("javascript"); | |
| 35 | + expect(resolveReviewHighlighterEngine("native", false)).toBe("javascript"); | |
| 36 | + }); | |
| 37 | +}); | |
| 38 | + | |
| 39 | +describe("hasTurboModuleProxy", () => { | |
| 40 | + it("returns false outside a native runtime", () => { | |
| 41 | + expect(hasTurboModuleProxy()).toBe(false); | |
| 42 | + }); | |
| 43 | +}); | |
| @@ -0,0 +1,36 @@ | ||
| 1 | +export type ReviewHighlighterEnginePreference = "auto" | "javascript" | "native"; | |
| 2 | +export type ReviewHighlighterEngine = "javascript" | "native"; | |
| 3 | + | |
| 4 | +export function resolveReviewHighlighterEnginePreference( | |
| 5 | + value: string | undefined, | |
| 6 | +): ReviewHighlighterEnginePreference { | |
| 7 | + switch (value) { | |
| 8 | + case "javascript": | |
| 9 | + case "native": | |
| 10 | + case "auto": | |
| 11 | + return value; | |
| 12 | + default: | |
| 13 | + return "auto"; | |
| 14 | + } | |
| 15 | +} | |
| 16 | + | |
| 17 | +export function resolveReviewHighlighterEngine( | |
| 18 | + preference: ReviewHighlighterEnginePreference, | |
| 19 | + nativeAvailable: boolean, | |
| 20 | +): ReviewHighlighterEngine { | |
| 21 | + if (preference === "javascript") { | |
| 22 | + return "javascript"; | |
| 23 | + } | |
| 24 | + | |
| 25 | + if (nativeAvailable) { | |
| 26 | + return "native"; | |
| 27 | + } | |
| 28 | + | |
| 29 | + return "javascript"; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export function hasTurboModuleProxy(): boolean { | |
| 33 | + const turboModuleProxy = (globalThis as { readonly __turboModuleProxy?: unknown }) | |
| 34 | + .__turboModuleProxy; | |
| 35 | + return typeof turboModuleProxy === "function"; | |
| 36 | +} | |
| @@ -11,7 +11,9 @@ import { · 10 unmodified lines | ||
| 11 | 11 | buildReviewParsedDiff, |
| 12 | 12 | buildReviewSectionItems, |
| 13 | 13 | getDefaultReviewSectionId, |
| 14 | + getReviewFilePreviewState, | |
| 14 | 15 | getReviewSectionIdForCheckpoint, |
| 16 | + type ReviewRenderableFile, | |
| 15 | 17 | } from "./reviewModel"; |
| 16 | 18 | |
| 17 | 19 | function makeCheckpoint( |
| @@ -27,6 +29,24 @@ function makeCheckpoint( · 9 unmodified lines | ||
| 27 | 29 | }; |
| 28 | 30 | } |
| 29 | 31 | |
| 32 | +function makeRenderableFile( | |
| 33 | + input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">, | |
| 34 | +): ReviewRenderableFile { | |
| 35 | + return { | |
| 36 | + id: input.path, | |
| 37 | + cacheKey: input.path, | |
| 38 | + previousPath: null, | |
| 39 | + changeType: "new", | |
| 40 | + additions: 0, | |
| 41 | + deletions: 0, | |
| 42 | + languageHint: null, | |
| 43 | + additionLines: [], | |
| 44 | + deletionLines: [], | |
| 45 | + rows: [], | |
| 46 | + ...input, | |
| 47 | + }; | |
| 48 | +} | |
| 49 | + | |
| 30 | 50 | describe("buildReviewSectionItems", () => { |
| 31 | 51 | it("keeps one chip per checkpoint and appends git sources", () => { |
| 32 | 52 | const checkpoints = [ |
| @@ -171,4 +191,44 @@ describe("buildReviewParsedDiff", () => { · 138 unmodified lines | ||
| 171 | 191 | header: "@@ -1,1 +1,2 @@", |
| 172 | 192 | }); |
| 173 | 193 | }); |
| 194 | + | |
| 195 | + it("suppresses preview for non-text file formats", () => { | |
| 196 | + const preview = getReviewFilePreviewState( | |
| 197 | + makeRenderableFile({ | |
| 198 | + path: "apps/mobile/assets/icon.png", | |
| 199 | + }), | |
| 200 | + ); | |
| 201 | + | |
| 202 | + expect(preview).toMatchObject({ | |
| 203 | + kind: "suppressed", | |
| 204 | + reason: "non-text", | |
| 205 | + title: "Non-text file", | |
| 206 | + actionLabel: null, | |
| 207 | + }); | |
| 208 | + }); | |
| 209 | + | |
| 210 | + it("suppresses large diffs until explicitly requested", () => { | |
| 211 | + const preview = getReviewFilePreviewState( | |
| 212 | + makeRenderableFile({ | |
| 213 | + path: "apps/mobile/src/big.ts", | |
| 214 | + rows: Array.from({ length: 401 }, (_, index) => ({ | |
| 215 | + kind: "line" as const, | |
| 216 | + id: `line-${index}`, | |
| 217 | + change: "add" as const, | |
| 218 | + oldLineNumber: null, | |
| 219 | + newLineNumber: index + 1, | |
| 220 | + content: `const line${index} = ${index};`, | |
| 221 | + additionTokenIndex: index, | |
| 222 | + deletionTokenIndex: null, | |
| 223 | + })), | |
| 224 | + }), | |
| 225 | + ); | |
| 226 | + | |
| 227 | + expect(preview).toMatchObject({ | |
| 228 | + kind: "suppressed", | |
| 229 | + reason: "large", | |
| 230 | + title: "Large diff", | |
| 231 | + actionLabel: "Load diff", | |
| 232 | + }); | |
| 233 | + }); | |
| 174 | 234 | }); |
| @@ -48,6 +48,18 @@ export interface ReviewRenderableFile { · 47 unmodified lines | ||
| 48 | 48 | readonly rows: ReadonlyArray<ReviewRenderableRow>; |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | +export type ReviewFilePreviewState = | |
| 52 | + | { | |
| 53 | + readonly kind: "render"; | |
| 54 | + } | |
| 55 | + | { | |
| 56 | + readonly kind: "suppressed"; | |
| 57 | + readonly reason: "non-text" | "large"; | |
| 58 | + readonly title: string; | |
| 59 | + readonly message: string; | |
| 60 | + readonly actionLabel: string | null; | |
| 61 | + }; | |
| 62 | + | |
| 51 | 63 | export type ReviewParsedDiff = |
| 52 | 64 | | { |
| 53 | 65 | readonly kind: "empty"; |
| @@ -147,6 +159,50 @@ const FNV_OFFSET_BASIS_32 = 0x811c9dc5; · 93 unmodified lines | ||
| 147 | 159 | const FNV_PRIME_32 = 0x01000193; |
| 148 | 160 | const SECONDARY_HASH_SEED = 0x9e3779b9; |
| 149 | 161 | const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b; |
| 162 | +const LARGE_DIFF_LINE_THRESHOLD = 400; | |
| 163 | +const LARGE_DIFF_CHARACTER_THRESHOLD = 24_000; | |
| 164 | +const NON_TEXT_FILE_EXTENSIONS = new Set([ | |
| 165 | + "png", | |
| 166 | + "jpg", | |
| 167 | + "jpeg", | |
| 168 | + "gif", | |
| 169 | + "webp", | |
| 170 | + "bmp", | |
| 171 | + "ico", | |
| 172 | + "icns", | |
| 173 | + "avif", | |
| 174 | + "heic", | |
| 175 | + "tif", | |
| 176 | + "tiff", | |
| 177 | + "mp3", | |
| 178 | + "wav", | |
| 179 | + "flac", | |
| 180 | + "ogg", | |
| 181 | + "m4a", | |
| 182 | + "aac", | |
| 183 | + "mp4", | |
| 184 | + "mov", | |
| 185 | + "avi", | |
| 186 | + "mkv", | |
| 187 | + "webm", | |
| 188 | + "pdf", | |
| 189 | + "zip", | |
| 190 | + "gz", | |
| 191 | + "tgz", | |
| 192 | + "bz2", | |
| 193 | + "7z", | |
| 194 | + "rar", | |
| 195 | + "woff", | |
| 196 | + "woff2", | |
| 197 | + "ttf", | |
| 198 | + "otf", | |
| 199 | + "eot", | |
| 200 | + "wasm", | |
| 201 | + "exe", | |
| 202 | + "dll", | |
| 203 | + "so", | |
| 204 | + "dylib", | |
| 205 | +]); | |
| 150 | 206 | |
| 151 | 207 | function fnv1a32(input: string, seed: number, multiplier: number): number { |
| 152 | 208 | let hash = seed >>> 0; |
| @@ -168,6 +224,49 @@ function buildPatchCacheKey(patch: string, scope: string): string { · 15 unmodified lines | ||
| 168 | 224 | return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`; |
| 169 | 225 | } |
| 170 | 226 | |
| 227 | +function getFileExtension(path: string): string | null { | |
| 228 | + const match = /\.([a-z0-9]+)$/i.exec(path); | |
| 229 | + return match?.[1]?.toLowerCase() ?? null; | |
| 230 | +} | |
| 231 | + | |
| 232 | +function countReviewRenderableLineRows(file: ReviewRenderableFile): number { | |
| 233 | + return file.rows.reduce((total, row) => total + (row.kind === "line" ? 1 : 0), 0); | |
| 234 | +} | |
| 235 | + | |
| 236 | +function countReviewRenderableCharacters(file: ReviewRenderableFile): number { | |
| 237 | + return file.rows.reduce( | |
| 238 | + (total, row) => total + (row.kind === "line" ? row.content.length : row.header.length), | |
| 239 | + 0, | |
| 240 | + ); | |
| 241 | +} | |
| 242 | + | |
| 243 | +export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFilePreviewState { | |
| 244 | + const extension = getFileExtension(file.path); | |
| 245 | + if (extension && NON_TEXT_FILE_EXTENSIONS.has(extension)) { | |
| 246 | + return { | |
| 247 | + kind: "suppressed", | |
| 248 | + reason: "non-text", | |
| 249 | + title: "Non-text file", | |
| 250 | + message: "Diff preview is not available for this file format.", | |
| 251 | + actionLabel: null, | |
| 252 | + }; | |
| 253 | + } | |
| 254 | + | |
| 255 | + const lineCount = countReviewRenderableLineRows(file); | |
| 256 | + const characterCount = countReviewRenderableCharacters(file); | |
| 257 | + if (lineCount > LARGE_DIFF_LINE_THRESHOLD || characterCount > LARGE_DIFF_CHARACTER_THRESHOLD) { | |
| 258 | + return { | |
| 259 | + kind: "suppressed", | |
| 260 | + reason: "large", | |
| 261 | + title: "Large diff", | |
| 262 | + message: "Large diffs are not rendered by default.", | |
| 263 | + actionLabel: "Load diff", | |
| 264 | + }; | |
| 265 | + } | |
| 266 | + | |
| 267 | + return { kind: "render" }; | |
| 268 | +} | |
| 269 | + | |
| 171 | 270 | function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { |
| 172 | 271 | return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`; |
| 173 | 272 | } |
| @@ -0,0 +1,195 @@ | ||
| 1 | +import { useAtomValue } from "@effect/atom-react"; | |
| 2 | + | |
| 3 | +import { EnvironmentId, ThreadId, type GitReviewDiffSection } from "@t3tools/contracts"; | |
| 4 | +import { Atom } from "effect/unstable/reactivity"; | |
| 5 | + | |
| 6 | +import { scopedThreadKey } from "../../../lib/scopedEntities"; | |
| 7 | +import { appAtomRegistry } from "../../../state/atom-registry"; | |
| 8 | +import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel"; | |
| 9 | + | |
| 10 | +const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze<ReadonlyArray<GitReviewDiffSection>>([]); | |
| 11 | +const EMPTY_REVIEW_TURN_DIFFS = Object.freeze<Readonly<Record<string, string>>>({}); | |
| 12 | +const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze< | |
| 13 | + Readonly<Record<string, ReadonlyArray<string> | undefined>> | |
| 14 | +>({}); | |
| 15 | +const EMPTY_REVIEW_GIT_SECTIONS_ATOM = Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe( | |
| 16 | + Atom.keepAlive, | |
| 17 | + Atom.withLabel("mobile:review:git-sections:null"), | |
| 18 | +); | |
| 19 | +const EMPTY_REVIEW_TURN_DIFFS_ATOM = Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe( | |
| 20 | + Atom.keepAlive, | |
| 21 | + Atom.withLabel("mobile:review:turn-diffs:null"), | |
| 22 | +); | |
| 23 | +const EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM = Atom.make<string | null>(null).pipe( | |
| 24 | + Atom.keepAlive, | |
| 25 | + Atom.withLabel("mobile:review:selected-section-id:null"), | |
| 26 | +); | |
| 27 | +const EMPTY_REVIEW_SECTION_FILE_IDS_ATOM = Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( | |
| 28 | + Atom.keepAlive, | |
| 29 | + Atom.withLabel("mobile:review:section-file-ids:null"), | |
| 30 | +); | |
| 31 | + | |
| 32 | +const reviewGitSectionsByThreadKeyAtom = Atom.family((threadKey: string) => | |
| 33 | + Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe( | |
| 34 | + Atom.keepAlive, | |
| 35 | + Atom.withLabel(`mobile:review:git-sections:${threadKey}`), | |
| 36 | + ), | |
| 37 | +); | |
| 38 | + | |
| 39 | +const reviewTurnDiffByThreadKeyAtom = Atom.family((threadKey: string) => | |
| 40 | + Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe( | |
| 41 | + Atom.keepAlive, | |
| 42 | + Atom.withLabel(`mobile:review:turn-diffs:${threadKey}`), | |
| 43 | + ), | |
| 44 | +); | |
| 45 | + | |
| 46 | +const reviewSelectedSectionIdByThreadKeyAtom = Atom.family((threadKey: string) => | |
| 47 | + Atom.make<string | null>(null).pipe( | |
| 48 | + Atom.keepAlive, | |
| 49 | + Atom.withLabel(`mobile:review:selected-section-id:${threadKey}`), | |
| 50 | + ), | |
| 51 | +); | |
| 52 | + | |
| 53 | +const reviewExpandedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => | |
| 54 | + Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( | |
| 55 | + Atom.keepAlive, | |
| 56 | + Atom.withLabel(`mobile:review:expanded-file-ids:${threadKey}`), | |
| 57 | + ), | |
| 58 | +); | |
| 59 | + | |
| 60 | +const reviewRevealedLargeFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => | |
| 61 | + Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( | |
| 62 | + Atom.keepAlive, | |
| 63 | + Atom.withLabel(`mobile:review:revealed-large-file-ids:${threadKey}`), | |
| 64 | + ), | |
| 65 | +); | |
| 66 | + | |
| 67 | +const reviewParsedDiffBySectionCacheKeyAtom = Atom.family((cacheKey: string) => | |
| 68 | + Atom.make<{ readonly diff: string | null; readonly parsed: ReviewParsedDiff } | null>(null).pipe( | |
| 69 | + Atom.keepAlive, | |
| 70 | + Atom.withLabel(`mobile:review:parsed-diffs:${cacheKey}`), | |
| 71 | + ), | |
| 72 | +); | |
| 73 | + | |
| 74 | +function buildThreadKey(input: { | |
| 75 | + readonly environmentId?: string; | |
| 76 | + readonly threadId?: string; | |
| 77 | +}): string | null { | |
| 78 | + return input.environmentId && input.threadId | |
| 79 | + ? scopedThreadKey(EnvironmentId.make(input.environmentId), ThreadId.make(input.threadId)) | |
| 80 | + : null; | |
| 81 | +} | |
| 82 | + | |
| 83 | +function buildSectionCacheKey(threadKey: string, sectionId: string): string { | |
| 84 | + return `${threadKey}:${sectionId}`; | |
| 85 | +} | |
| 86 | + | |
| 87 | +export function useReviewCacheForThread(input: { | |
| 88 | + readonly environmentId?: string; | |
| 89 | + readonly threadId?: string; | |
| 90 | +}) { | |
| 91 | + const threadKey = buildThreadKey(input); | |
| 92 | + const gitSections = useAtomValue( | |
| 93 | + threadKey ? reviewGitSectionsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_GIT_SECTIONS_ATOM, | |
| 94 | + ); | |
| 95 | + const turnDiffById = useAtomValue( | |
| 96 | + threadKey ? reviewTurnDiffByThreadKeyAtom(threadKey) : EMPTY_REVIEW_TURN_DIFFS_ATOM, | |
| 97 | + ); | |
| 98 | + const selectedSectionId = useAtomValue( | |
| 99 | + threadKey | |
| 100 | + ? reviewSelectedSectionIdByThreadKeyAtom(threadKey) | |
| 101 | + : EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM, | |
| 102 | + ); | |
| 103 | + const expandedFileIdsBySection = useAtomValue( | |
| 104 | + threadKey | |
| 105 | + ? reviewExpandedFileIdsByThreadKeyAtom(threadKey) | |
| 106 | + : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM, | |
| 107 | + ); | |
| 108 | + const revealedLargeFileIdsBySection = useAtomValue( | |
| 109 | + threadKey | |
| 110 | + ? reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey) | |
| 111 | + : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM, | |
| 112 | + ); | |
| 113 | + | |
| 114 | + return { | |
| 115 | + threadKey, | |
| 116 | + gitSections, | |
| 117 | + turnDiffById, | |
| 118 | + selectedSectionId, | |
| 119 | + expandedFileIdsBySection, | |
| 120 | + revealedLargeFileIdsBySection, | |
| 121 | + }; | |
| 122 | +} | |
| 123 | + | |
| 124 | +export function setReviewGitSections( | |
| 125 | + threadKey: string, | |
| 126 | + sections: ReadonlyArray<GitReviewDiffSection>, | |
| 127 | +): void { | |
| 128 | + appAtomRegistry.set(reviewGitSectionsByThreadKeyAtom(threadKey), sections); | |
| 129 | +} | |
| 130 | + | |
| 131 | +export function setReviewTurnDiff(threadKey: string, sectionId: string, diff: string): void { | |
| 132 | + const atom = reviewTurnDiffByThreadKeyAtom(threadKey); | |
| 133 | + const current = appAtomRegistry.get(atom); | |
| 134 | + appAtomRegistry.set(atom, { | |
| 135 | + ...current, | |
| 136 | + [sectionId]: diff, | |
| 137 | + }); | |
| 138 | +} | |
| 139 | + | |
| 140 | +export function setReviewSelectedSectionId(threadKey: string, sectionId: string | null): void { | |
| 141 | + appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId); | |
| 142 | +} | |
| 143 | + | |
| 144 | +export function updateReviewExpandedFileIds( | |
| 145 | + threadKey: string, | |
| 146 | + sectionId: string, | |
| 147 | + update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined, | |
| 148 | +): void { | |
| 149 | + const atom = reviewExpandedFileIdsByThreadKeyAtom(threadKey); | |
| 150 | + const current = appAtomRegistry.get(atom); | |
| 151 | + const nextValue = update(current[sectionId]); | |
| 152 | + appAtomRegistry.set(atom, { | |
| 153 | + ...current, | |
| 154 | + [sectionId]: nextValue, | |
| 155 | + }); | |
| 156 | +} | |
| 157 | + | |
| 158 | +export function updateReviewRevealedLargeFileIds( | |
| 159 | + threadKey: string, | |
| 160 | + sectionId: string, | |
| 161 | + update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined, | |
| 162 | +): void { | |
| 163 | + const atom = reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey); | |
| 164 | + const current = appAtomRegistry.get(atom); | |
| 165 | + const nextValue = update(current[sectionId]); | |
| 166 | + appAtomRegistry.set(atom, { | |
| 167 | + ...current, | |
| 168 | + [sectionId]: nextValue, | |
| 169 | + }); | |
| 170 | +} | |
| 171 | + | |
| 172 | +export function getCachedReviewParsedDiff(input: { | |
| 173 | + readonly threadKey: string | null; | |
| 174 | + readonly sectionId: string | null; | |
| 175 | + readonly diff: string | null | undefined; | |
| 176 | +}): ReviewParsedDiff { | |
| 177 | + if (!input.threadKey || !input.sectionId) { | |
| 178 | + return buildReviewParsedDiff(input.diff, input.sectionId ?? "mobile-review"); | |
| 179 | + } | |
| 180 | + | |
| 181 | + const cacheKey = buildSectionCacheKey(input.threadKey, input.sectionId); | |
| 182 | + const normalizedDiff = input.diff?.trim() ?? null; | |
| 183 | + const atom = reviewParsedDiffBySectionCacheKeyAtom(cacheKey); | |
| 184 | + const cached = appAtomRegistry.get(atom); | |
| 185 | + if (cached && cached.diff === normalizedDiff) { | |
| 186 | + return cached.parsed; | |
| 187 | + } | |
| 188 | + | |
| 189 | + const parsed = buildReviewParsedDiff(input.diff, input.sectionId); | |
| 190 | + appAtomRegistry.set(atom, { | |
| 191 | + diff: normalizedDiff, | |
| 192 | + parsed, | |
| 193 | + }); | |
| 194 | + return parsed; | |
| 195 | +} | |
| @@ -0,0 +1,44 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | + | |
| 3 | +import type { ReviewRenderableFile } from "./reviewModel"; | |
| 4 | +import { highlightReviewFile } from "./shikiReviewHighlighter"; | |
| 5 | + | |
| 6 | +function makeRenderableFile( | |
| 7 | + input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">, | |
| 8 | +): ReviewRenderableFile { | |
| 9 | + return { | |
| 10 | + id: input.path, | |
| 11 | + cacheKey: input.path, | |
| 12 | + previousPath: null, | |
| 13 | + changeType: "new", | |
| 14 | + additions: 0, | |
| 15 | + deletions: 0, | |
| 16 | + languageHint: null, | |
| 17 | + additionLines: [], | |
| 18 | + deletionLines: [], | |
| 19 | + rows: [], | |
| 20 | + ...input, | |
| 21 | + }; | |
| 22 | +} | |
| 23 | + | |
| 24 | +describe("highlightReviewFile", () => { | |
| 25 | + it("preserves one highlighted token row per diff line even without trailing newlines", async () => { | |
| 26 | + const file = makeRenderableFile({ | |
| 27 | + path: "apps/mobile/src/example.test.ts", | |
| 28 | + additionLines: [ | |
| 29 | + 'const items = ["a"];', | |
| 30 | + 'expect(items).toEqual(["a"]);', | |
| 31 | + "const next = items.map((item) => item.toUpperCase());", | |
| 32 | + 'expect(next).toContain("A");', | |
| 33 | + ], | |
| 34 | + }); | |
| 35 | + | |
| 36 | + const highlighted = await highlightReviewFile(file, "light"); | |
| 37 | + | |
| 38 | + expect(highlighted.additionLines).toHaveLength(file.additionLines.length); | |
| 39 | + expect(highlighted.additionLines[0]?.some((token) => token.content === "const")).toBe(true); | |
| 40 | + expect(highlighted.additionLines[1]?.some((token) => token.content === "expect")).toBe(true); | |
| 41 | + expect(highlighted.additionLines[2]?.some((token) => token.content === "const")).toBe(true); | |
| 42 | + expect(highlighted.additionLines[3]?.some((token) => token.content === "expect")).toBe(true); | |
| 43 | + }); | |
| 44 | +}); | |
| @@ -1,6 +1,12 @@ | ||
| 1 | 1 | import { getFiletypeFromFileName } from "@pierre/diffs/utils"; |
| 2 | 2 | |
| 3 | −import type { ReviewRenderableFile } from "./reviewModel"; | |
| 3 | +import { | |
| 4 | + hasTurboModuleProxy, | |
| 5 | + resolveReviewHighlighterEngine, | |
| 6 | + resolveReviewHighlighterEnginePreference, | |
| 7 | + type ReviewHighlighterEngine, | |
| 8 | +} from "./reviewHighlighterEngine"; | |
| 9 | +import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel"; | |
| 4 | 10 | |
| 5 | 11 | export type ReviewDiffTheme = "light" | "dark"; |
| 6 | 12 | |
| @@ -19,8 +25,12 @@ const SHIKI_THEME_BY_SCHEME = { · 12 unmodified lines | ||
| 19 | 25 | light: "github-light-default", |
| 20 | 26 | dark: "github-dark-default", |
| 21 | 27 | } as const; |
| 28 | +const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( | |
| 29 | + process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE, | |
| 30 | +); | |
| 22 | 31 | |
| 23 | 32 | const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>(); |
| 33 | +const resolvedHighlightCache = new Map<string, ReviewHighlightedFile>(); | |
| 24 | 34 | const loadedLanguages = new Set<string>(["text"]); |
| 25 | 35 | type ShikiHighlighter = { |
| 26 | 36 | loadLanguage: (...langs: string[]) => Promise<void>; |
| @@ -30,38 +40,83 @@ type ShikiHighlighter = { · 3 unmodified lines | ||
| 30 | 40 | ) => Promise<Array<Array<{ content: string; color?: string; fontStyle?: number }>>>; |
| 31 | 41 | }; |
| 32 | 42 | let highlighterPromise: Promise<ShikiHighlighter> | null = null; |
| 43 | +let activeHighlighterEnginePromise: Promise<ReviewHighlighterEngine> | null = null; | |
| 33 | 44 | |
| 34 | −function cleanLastNewline(value: string): string { | |
| 45 | +function stripTrailingNewline(value: string): string { | |
| 35 | 46 | return value.endsWith("\n") ? value.slice(0, -1) : value; |
| 36 | 47 | } |
| 37 | 48 | |
| 38 | 49 | function joinPatchLines(lines: ReadonlyArray<string>): string { |
| 39 | − if (lines.length === 0) { | |
| 40 | − return ""; | |
| 41 | − } | |
| 42 | − | |
| 43 | − return cleanLastNewline(lines.join("")); | |
| 50 | + return lines.map(stripTrailingNewline).join("\n"); | |
| 44 | 51 | } |
| 45 | 52 | |
| 46 | 53 | async function getHighlighter() { |
| 47 | 54 | if (!highlighterPromise) { |
| 48 | − highlighterPromise = import("shiki").then( | |
| 49 | − async ({ createHighlighter, createJavaScriptRegexEngine }) => { | |
| 50 | − const highlighter = await createHighlighter({ | |
| 51 | − themes: [SHIKI_THEME_BY_SCHEME.light, SHIKI_THEME_BY_SCHEME.dark], | |
| 52 | − langs: [], | |
| 53 | − engine: createJavaScriptRegexEngine(), | |
| 54 | − }); | |
| 55 | − return highlighter as unknown as ShikiHighlighter; | |
| 56 | − }, | |
| 57 | − ); | |
| 55 | + const configuredHighlighterPromise = import("shiki").then(async (shikiModule) => { | |
| 56 | + const nativeRuntimeAvailable = hasTurboModuleProxy(); | |
| 57 | + let nativeEngineAvailable = false; | |
| 58 | + | |
| 59 | + if (nativeRuntimeAvailable && REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE !== "javascript") { | |
| 60 | + try { | |
| 61 | + const nativeEngineModule = await import("react-native-shiki-engine"); | |
| 62 | + nativeEngineAvailable = nativeEngineModule.isNativeEngineAvailable(); | |
| 63 | + if (nativeEngineAvailable) { | |
| 64 | + const createHighlighter = shikiModule.createBundledHighlighter({ | |
| 65 | + langs: shikiModule.bundledLanguages, | |
| 66 | + themes: shikiModule.bundledThemes, | |
| 67 | + engine: () => nativeEngineModule.createNativeEngine(), | |
| 68 | + }); | |
| 69 | + const highlighter = await createHighlighter({ | |
| 70 | + themes: [SHIKI_THEME_BY_SCHEME.light, SHIKI_THEME_BY_SCHEME.dark], | |
| 71 | + langs: [], | |
| 72 | + }); | |
| 73 | + return { | |
| 74 | + highlighter: highlighter as unknown as ShikiHighlighter, | |
| 75 | + engine: "native" as const, | |
| 76 | + }; | |
| 77 | + } | |
| 78 | + } catch { | |
| 79 | + nativeEngineAvailable = false; | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + const engine = resolveReviewHighlighterEngine( | |
| 84 | + REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, | |
| 85 | + nativeEngineAvailable, | |
| 86 | + ); | |
| 87 | + const createHighlighter = shikiModule.createBundledHighlighter({ | |
| 88 | + langs: shikiModule.bundledLanguages, | |
| 89 | + themes: shikiModule.bundledThemes, | |
| 90 | + engine: () => shikiModule.createJavaScriptRegexEngine(), | |
| 91 | + }); | |
| 92 | + const highlighter = await createHighlighter({ | |
| 93 | + themes: [SHIKI_THEME_BY_SCHEME.light, SHIKI_THEME_BY_SCHEME.dark], | |
| 94 | + langs: [], | |
| 95 | + }); | |
| 96 | + return { | |
| 97 | + highlighter: highlighter as unknown as ShikiHighlighter, | |
| 98 | + engine, | |
| 99 | + }; | |
| 100 | + }); | |
| 101 | + | |
| 102 | + highlighterPromise = configuredHighlighterPromise.then((result) => result.highlighter); | |
| 103 | + activeHighlighterEnginePromise = configuredHighlighterPromise.then((result) => result.engine); | |
| 58 | 104 | } |
| 59 | 105 | |
| 60 | 106 | return highlighterPromise as Promise<ShikiHighlighter>; |
| 61 | 107 | } |
| 62 | 108 | |
| 63 | −async function resolveLanguage(file: ReviewRenderableFile): Promise<string> { | |
| 64 | − const candidate = file.languageHint ?? getFiletypeFromFileName(file.path); | |
| 109 | +export async function getActiveReviewHighlighterEngine(): Promise<ReviewHighlighterEngine> { | |
| 110 | + await getHighlighter(); | |
| 111 | + return (activeHighlighterEnginePromise ?? | |
| 112 | + Promise.resolve("javascript")) as Promise<ReviewHighlighterEngine>; | |
| 113 | +} | |
| 114 | + | |
| 115 | +async function resolveLanguageFromPath( | |
| 116 | + path: string, | |
| 117 | + languageHint: string | null = null, | |
| 118 | +): Promise<string> { | |
| 119 | + const candidate = languageHint ?? getFiletypeFromFileName(path); | |
| 65 | 120 | if (!candidate || candidate === "text" || candidate === "ansi") { |
| 66 | 121 | return "text"; |
| 67 | 122 | } |
| @@ -79,6 +134,10 @@ async function resolveLanguage(file: ReviewRenderableFile): Promise<string> { · 11 unmodified lines | ||
| 79 | 134 | return candidate; |
| 80 | 135 | } |
| 81 | 136 | |
| 137 | +async function resolveLanguage(file: ReviewRenderableFile): Promise<string> { | |
| 138 | + return resolveLanguageFromPath(file.path, file.languageHint); | |
| 139 | +} | |
| 140 | + | |
| 82 | 141 | function normalizeHighlightedLines( |
| 83 | 142 | tokenLines: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>, |
| 84 | 143 | ): ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> { |
| @@ -105,12 +164,27 @@ async function highlightLines( · 20 unmodified lines | ||
| 105 | 164 | return normalizeHighlightedLines(tokenLines); |
| 106 | 165 | } |
| 107 | 166 | |
| 167 | +function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string { | |
| 168 | + return `${SHIKI_THEME_BY_SCHEME[theme]}:${file.cacheKey}`; | |
| 169 | +} | |
| 170 | + | |
| 171 | +export function getCachedHighlightedReviewFile( | |
| 172 | + file: ReviewRenderableFile, | |
| 173 | + theme: ReviewDiffTheme, | |
| 174 | +): ReviewHighlightedFile | null { | |
| 175 | + return resolvedHighlightCache.get(getHighlightCacheKey(file, theme)) ?? null; | |
| 176 | +} | |
| 177 | + | |
| 108 | 178 | export async function highlightReviewFile( |
| 109 | 179 | file: ReviewRenderableFile, |
| 110 | 180 | theme: ReviewDiffTheme, |
| 111 | 181 | ): Promise<ReviewHighlightedFile> { |
| 112 | 182 | const shikiTheme = SHIKI_THEME_BY_SCHEME[theme]; |
| 113 | − const cacheKey = `${shikiTheme}:${file.cacheKey}`; | |
| 183 | + const cacheKey = getHighlightCacheKey(file, theme); | |
| 184 | + const resolved = resolvedHighlightCache.get(cacheKey); | |
| 185 | + if (resolved) { | |
| 186 | + return resolved; | |
| 187 | + } | |
| 114 | 188 | const cached = highlightCache.get(cacheKey); |
| 115 | 189 | if (cached) { |
| 116 | 190 | return cached; |
| @@ -123,9 +197,52 @@ export async function highlightReviewFile( · 6 unmodified lines | ||
| 123 | 197 | highlightLines(joinPatchLines(file.deletionLines), language, shikiTheme), |
| 124 | 198 | ]); |
| 125 | 199 | |
| 126 | − return { additionLines, deletionLines }; | |
| 200 | + const highlighted = { additionLines, deletionLines }; | |
| 201 | + resolvedHighlightCache.set(cacheKey, highlighted); | |
| 202 | + return highlighted; | |
| 127 | 203 | })(); |
| 128 | 204 | |
| 129 | 205 | highlightCache.set(cacheKey, promise); |
| 130 | 206 | return promise; |
| 131 | 207 | } |
| 208 | + | |
| 209 | +export async function highlightReviewSelectedLines(input: { | |
| 210 | + readonly filePath: string; | |
| 211 | + readonly lines: ReadonlyArray<ReviewRenderableLineRow>; | |
| 212 | + readonly theme: ReviewDiffTheme; | |
| 213 | + readonly languageHint?: string | null; | |
| 214 | +}): Promise<Record<string, ReadonlyArray<ReviewHighlightedToken>>> { | |
| 215 | + if (input.lines.length === 0) { | |
| 216 | + return {}; | |
| 217 | + } | |
| 218 | + | |
| 219 | + const language = await resolveLanguageFromPath(input.filePath, input.languageHint ?? null); | |
| 220 | + const shikiTheme = SHIKI_THEME_BY_SCHEME[input.theme]; | |
| 221 | + const additionLikeLines = input.lines | |
| 222 | + .filter((line) => line.change !== "delete") | |
| 223 | + .map((line) => `${line.content}\n`); | |
| 224 | + const deletionLines = input.lines | |
| 225 | + .filter((line) => line.change === "delete") | |
| 226 | + .map((line) => `${line.content}\n`); | |
| 227 | + const [additionTokens, deletionTokens] = await Promise.all([ | |
| 228 | + highlightLines(joinPatchLines(additionLikeLines), language, shikiTheme), | |
| 229 | + highlightLines(joinPatchLines(deletionLines), language, shikiTheme), | |
| 230 | + ]); | |
| 231 | + | |
| 232 | + const tokenMap: Record<string, ReadonlyArray<ReviewHighlightedToken>> = {}; | |
| 233 | + let additionIndex = 0; | |
| 234 | + let deletionIndex = 0; | |
| 235 | + | |
| 236 | + input.lines.forEach((line) => { | |
| 237 | + if (line.change === "delete") { | |
| 238 | + tokenMap[line.id] = deletionTokens[deletionIndex] ?? []; | |
| 239 | + deletionIndex += 1; | |
| 240 | + return; | |
| 241 | + } | |
| 242 | + | |
| 243 | + tokenMap[line.id] = additionTokens[additionIndex] ?? []; | |
| 244 | + additionIndex += 1; | |
| 245 | + }); | |
| 246 | + | |
| 247 | + return tokenMap; | |
| 248 | +} | |
| @@ -93,6 +93,7 @@ export function appendReviewCommentToDraft(input: { · 92 unmodified lines | ||
| 93 | 93 | readonly environmentId: string; |
| 94 | 94 | readonly threadId: string; |
| 95 | 95 | readonly text: string; |
| 96 | + readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>; | |
| 96 | 97 | }): void { |
| 97 | 98 | const threadKey = scopedThreadKey( |
| 98 | 99 | EnvironmentId.make(input.environmentId), |
| @@ -105,6 +106,26 @@ export function appendReviewCommentToDraft(input: { · 6 unmodified lines | ||
| 105 | 106 | ...current, |
| 106 | 107 | [threadKey]: `${existing}${separator}${input.text}`, |
| 107 | 108 | }); |
| 109 | + if (input.attachments && input.attachments.length > 0) { | |
| 110 | + appendDraftAttachments(threadKey, input.attachments); | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +export function useThreadDraftForThread(input: { | |
| 115 | + readonly environmentId?: string; | |
| 116 | + readonly threadId?: string; | |
| 117 | +}) { | |
| 118 | + const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom); | |
| 119 | + const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom); | |
| 120 | + const threadKey = | |
| 121 | + input.environmentId && input.threadId | |
| 122 | + ? scopedThreadKey(EnvironmentId.make(input.environmentId), ThreadId.make(input.threadId)) | |
| 123 | + : null; | |
| 124 | + | |
| 125 | + return { | |
| 126 | + draftMessage: threadKey ? (draftMessageByThreadKey[threadKey] ?? "") : "", | |
| 127 | + draftAttachments: threadKey ? (draftAttachmentsByThreadKey[threadKey] ?? []) : [], | |
| 128 | + }; | |
| 108 | 129 | } |
| 109 | 130 | |
| 110 | 131 | function clearDraft(threadKey: string): void { |
| @@ -85,6 +85,7 @@ · 84 unmodified lines | ||
| 85 | 85 | "react-native-reanimated": "4.2.1", |
| 86 | 86 | "react-native-safe-area-context": "~5.6.2", |
| 87 | 87 | "react-native-screens": "~4.23.0", |
| 88 | + "react-native-shiki-engine": "^0.3.9", | |
| 88 | 89 | "react-native-svg": "15.15.3", |
| 89 | 90 | "react-native-worklets": "0.7.2", |
| 90 | 91 | "shiki": "^4.0.2", |
| @@ -1071,7 +1072,7 @@ · 980 unmodified lines | ||
| 1071 | 1072 | |
| 1072 | 1073 | "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], |
| 1073 | 1074 | |
| 1074 | − "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], | |
| 1075 | + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], | |
| 1075 | 1076 | |
| 1076 | 1077 | "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], |
| 1077 | 1078 | |
| @@ -2453,6 +2454,8 @@ · 1375 unmodified lines | ||
| 2453 | 2454 | |
| 2454 | 2455 | "react-native-screens": ["react-native-screens@4.23.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-XhO3aK0UeLpBn4kLecd+J+EDeRRJlI/Ro9Fze06vo1q163VeYtzfU9QS09/VyDFMWR1qxDC1iazCArTPSFFiPw=="], |
| 2455 | 2456 | |
| 2457 | + "react-native-shiki-engine": ["react-native-shiki-engine@0.3.9", "", { "dependencies": { "@shikijs/types": "^3.19.0", "@shikijs/vscode-textmate": "^10.0.2" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FoCzxk6ODGqbYFXRAgnrZ+ZTt5gbCfvwxxobuvGtvjIAK3GtjV6gOImldgI2hB/oelwqw1gksrFjpN3BqE7jKA=="], | |
| 2458 | + | |
| 2456 | 2459 | "react-native-svg": ["react-native-svg@15.15.3", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/k4KYwPBLGcx2f5d4FjE+vCScK7QOX14cl2lIASJ28u4slHHtIhL0SZKU7u9qmRBHxTCKPoPBtN6haT1NENJNA=="], |
| 2457 | 2460 | |
| 2458 | 2461 | "react-native-worklets": ["react-native-worklets@0.7.2", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "7.27.1", "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-classes": "7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", "@babel/plugin-transform-optional-chaining": "7.27.1", "@babel/plugin-transform-shorthand-properties": "7.27.1", "@babel/plugin-transform-template-literals": "7.27.1", "@babel/plugin-transform-unicode-regex": "7.27.1", "@babel/preset-typescript": "7.27.1", "convert-source-map": "2.0.0", "semver": "7.7.3" }, "peerDependencies": { "@babel/core": "*", "react": "*", "react-native": "*" } }, "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog=="], |
| @@ -3023,9 +3026,19 @@ · 564 unmodified lines | ||
| 3023 | 3026 | |
| 3024 | 3027 | "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], |
| 3025 | 3028 | |
| 3026 | − "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], | |
| 3029 | + "@shikijs/core/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], | |
| 3030 | + | |
| 3031 | + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], | |
| 3032 | + | |
| 3033 | + "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], | |
| 3034 | + | |
| 3035 | + "@shikijs/langs/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], | |
| 3027 | 3036 | |
| 3028 | − "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], | |
| 3037 | + "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], | |
| 3038 | + | |
| 3039 | + "@shikijs/themes/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], | |
| 3040 | + | |
| 3041 | + "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], | |
| 3029 | 3042 | |
| 3030 | 3043 | "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], |
| 3031 | 3044 | |
| @@ -3205,6 +3218,8 @@ · 173 unmodified lines | ||
| 3205 | 3218 | |
| 3206 | 3219 | "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], |
| 3207 | 3220 | |
| 3221 | + "shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], | |
| 3222 | + | |
| 3208 | 3223 | "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], |
| 3209 | 3224 | |
| 3210 | 3225 | "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], |
| @@ -3275,8 +3290,6 @@ · 64 unmodified lines | ||
| 3275 | 3290 | |
| 3276 | 3291 | "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], |
| 3277 | 3292 | |
| 3278 | − "@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], | |
| 3279 | − | |
| 3280 | 3293 | "@react-native/babel-preset/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], |
| 3281 | 3294 | |
| 3282 | 3295 | "@react-native/codegen/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], |