- 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 | "**/routeTree.gen.ts", | 11 | "**/routeTree.gen.ts", |
| 12 | "apps/web/public/mockServiceWorker.js", | 12 | "apps/web/public/mockServiceWorker.js", |
| 13 | "apps/web/src/lib/vendor/qrcodegen.ts", | 13 | "apps/web/src/lib/vendor/qrcodegen.ts", |
| 14 | "apps/mobile/uniwind-types.d.ts" | 14 | "apps/mobile/uniwind-types.d.ts", |
| 15 | "*.icon/**" | 15 | "*.icon/**" |
| 16 | ], | 16 | ], |
| 17 | "sortPackageJson": {} | 17 | "sortPackageJson": {} |
| @@ -73,6 +73,7 @@ · 72 unmodified lines | |||
| 73 | "react-native-reanimated": "4.2.1", | 73 | "react-native-reanimated": "4.2.1", |
| 74 | "react-native-safe-area-context": "~5.6.2", | 74 | "react-native-safe-area-context": "~5.6.2", |
| 75 | "react-native-screens": "~4.23.0", | 75 | "react-native-screens": "~4.23.0", |
| 76 | "react-native-shiki-engine": "^0.3.9", | ||
| 76 | "react-native-svg": "15.15.3", | 77 | "react-native-svg": "15.15.3", |
| 77 | "react-native-worklets": "0.7.2", | 78 | "react-native-worklets": "0.7.2", |
| 78 | "shiki": "^4.0.2", | 79 | "shiki": "^4.0.2", |
| @@ -46,6 +46,7 @@ export default function ThreadLayout() { · 45 unmodified lines | |||
| 46 | options={{ | 46 | options={{ |
| 47 | animation: "slide_from_right", | 47 | animation: "slide_from_right", |
| 48 | contentStyle: sheetStyle, | 48 | contentStyle: sheetStyle, |
| 49 | headerBackButtonDisplayMode: "minimal", | ||
| 49 | headerShown: true, | 50 | headerShown: true, |
| 50 | headerTitle: "Files changed", | 51 | headerTitle: "Files changed", |
| 51 | headerBackTitle: "", | 52 | headerBackTitle: "", |
| @@ -15,6 +15,8 @@ export interface ComposerAttachmentStripProps { · 14 unmodified lines | |||
| 15 | readonly imageSize?: number; | 15 | readonly imageSize?: number; |
| 16 | /** Border radius of each image thumbnail. Defaults to 16. */ | 16 | /** Border radius of each image thumbnail. Defaults to 16. */ |
| 17 | readonly imageBorderRadius?: number; | 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 | const subtleBg = useThemeColor("--color-subtle"); | 27 | const subtleBg = useThemeColor("--color-subtle"); |
| 26 | const size = props.imageSize ?? 72; | 28 | const size = props.imageSize ?? 72; |
| 27 | const radius = props.imageBorderRadius ?? 16; | 29 | const radius = props.imageBorderRadius ?? 16; |
| 30 | const removeButtonPlacement = props.removeButtonPlacement ?? "overlay"; | ||
| 31 | const removeButtonGutter = removeButtonPlacement === "gutter" ? 10 : 0; | ||
| 28 | 32 | ||
| 29 | if (props.attachments.length === 0) { | 33 | if (props.attachments.length === 0) { |
| 30 | return null; | 34 | return null; |
| @@ -39,7 +43,14 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 8 unmodified lines | |||
| 39 | > | 43 | > |
| 40 | <View style={{ flexDirection: "row", gap: 10 }}> | 44 | <View style={{ flexDirection: "row", gap: 10 }}> |
| 41 | {props.attachments.map((image) => ( | 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 | <Pressable | 54 | <Pressable |
| 44 | onPress={props.onPressImage ? () => props.onPressImage!(image.previewUri) : undefined} | 55 | onPress={props.onPressImage ? () => props.onPressImage!(image.previewUri) : undefined} |
| 45 | > | 56 | > |
| @@ -57,8 +68,8 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 11 unmodified lines | |||
| 57 | <Pressable | 68 | <Pressable |
| 58 | style={{ | 69 | style={{ |
| 59 | position: "absolute", | 70 | position: "absolute", |
| 60 | top: 4, | 71 | top: removeButtonPlacement === "gutter" ? 0 : 4, |
| 61 | right: 4, | 72 | right: removeButtonPlacement === "gutter" ? 0 : 4, |
| 62 | width: 22, | 73 | width: 22, |
| 63 | height: 22, | 74 | height: 22, |
| 64 | borderRadius: 11, | 75 | borderRadius: 11, |
| @@ -1,12 +1,29 @@ | |||
| 1 | import { useLocalSearchParams, useRouter } from "expo-router"; | 1 | import { useLocalSearchParams, useRouter } from "expo-router"; |
| 2 | import { SymbolView } from "expo-symbols"; | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | import { useMemo, useState } from "react"; | 3 | import { TextInputWrapper } from "expo-paste-input"; |
| 4 | import { Platform, Pressable, ScrollView, Text as NativeText, View } from "react-native"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 15 | import ImageViewing from "react-native-image-viewing"; | ||
| 6 | 16 | ||
| 7 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; | 17 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; |
| 18 | import { ComposerAttachmentStrip } from "../../../components/ComposerAttachmentStrip"; | ||
| 19 | import { ControlPill } from "../../../components/ControlPill"; | ||
| 8 | import { cn } from "../../../lib/cn"; | 20 | import { cn } from "../../../lib/cn"; |
| 21 | import type { DraftComposerImageAttachment } from "../../../lib/composerImages"; | ||
| 22 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../../lib/composerImages"; | ||
| 9 | import { useThemeColor } from "../../../lib/useThemeColor"; | 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 | import { | 27 | import { |
| 11 | clearReviewCommentTarget, | 28 | clearReviewCommentTarget, |
| 12 | formatReviewCommentContext, | 29 | formatReviewCommentContext, |
| @@ -15,28 +32,21 @@ import { · 2 unmodified lines | |||
| 15 | getSelectedReviewCommentLines, | 32 | getSelectedReviewCommentLines, |
| 16 | useReviewCommentTarget, | 33 | useReviewCommentTarget, |
| 17 | } from "./reviewCommentSelection"; | 34 | } from "./reviewCommentSelection"; |
| 18 | import { appendReviewCommentToDraft } from "../use-thread-composer-state"; | 35 | import { changeTone, DiffTokenText } from "./reviewDiffRendering"; |
| 19 | 36 | import { | |
| 20 | const REVIEW_MONO_FONT_FAMILY = Platform.select({ | 37 | highlightReviewSelectedLines, |
| 21 | ios: "ui-monospace", | 38 | type ReviewDiffTheme, |
| 22 | android: "monospace", | 39 | type ReviewHighlightedToken, |
| 23 | default: "monospace", | 40 | } from "./shikiReviewHighlighter"; |
| 24 | }); | ||
| 25 | |||
| 26 | function renderVisibleWhitespace(value: string): string { | ||
| 27 | const expandedTabs = value.replace(/\t/g, " "); | ||
| 28 | return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0")); | ||
| 29 | } | ||
| 30 | 41 | ||
| 31 | function changeTone(change: "context" | "add" | "delete"): string { | 42 | const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5; |
| 32 | if (change === "add") return "bg-emerald-500/12"; | 43 | const REVIEW_COMMENT_LINE_HEIGHT = 26; |
| 33 | if (change === "delete") return "bg-rose-500/12"; | ||
| 34 | return "bg-card"; | ||
| 35 | } | ||
| 36 | 44 | ||
| 37 | export function ReviewCommentComposerSheet() { | 45 | export function ReviewCommentComposerSheet() { |
| 38 | const router = useRouter(); | 46 | const router = useRouter(); |
| 39 | const insets = useSafeAreaInsets(); | 47 | const insets = useSafeAreaInsets(); |
| 48 | const { width } = useWindowDimensions(); | ||
| 49 | const colorScheme = useColorScheme(); | ||
| 40 | const iconTint = String(useThemeColor("--color-icon")); | 50 | const iconTint = String(useThemeColor("--color-icon")); |
| 41 | const target = useReviewCommentTarget(); | 51 | const target = useReviewCommentTarget(); |
| 42 | const { environmentId, threadId } = useLocalSearchParams<{ | 52 | const { environmentId, threadId } = useLocalSearchParams<{ |
| @@ -44,6 +54,11 @@ export function ReviewCommentComposerSheet() { · 1 unmodified line | |||
| 44 | threadId: string; | 54 | threadId: string; |
| 45 | }>(); | 55 | }>(); |
| 46 | const [commentText, setCommentText] = useState(""); | 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 | const selectedLines = useMemo( | 63 | const selectedLines = useMemo( |
| 49 | () => (target ? getSelectedReviewCommentLines(target) : []), | 64 | () => (target ? getSelectedReviewCommentLines(target) : []), |
| @@ -53,136 +68,252 @@ export function ReviewCommentComposerSheet() { · 3 unmodified lines | |||
| 53 | const lastLine = selectedLines[selectedLines.length - 1] ?? null; | 68 | const lastLine = selectedLines[selectedLines.length - 1] ?? null; |
| 54 | const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null; | 69 | const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null; |
| 55 | const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null; | 70 | const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null; |
| 71 | const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme; | ||
| 56 | const canSubmit = | 72 | const canSubmit = |
| 57 | commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId; | 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 | return ( | 141 | return ( |
| 60 | <ScrollView | 142 | <KeyboardAvoidingView |
| 61 | keyboardShouldPersistTaps="handled" | 143 | behavior={Platform.OS === "ios" ? "padding" : "height"} |
| 62 | showsVerticalScrollIndicator={false} | 144 | keyboardVerticalOffset={Platform.OS === "ios" ? 12 : 0} |
| 63 | contentContainerStyle={{ | 145 | style={{ flex: 1 }} |
| 64 | paddingHorizontal: 20, | ||
| 65 | paddingTop: 8, | ||
| 66 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 67 | gap: 16, | ||
| 68 | }} | ||
| 69 | > | 146 | > |
| 70 | <View className="flex-row items-center justify-between py-2"> | 147 | <View |
| 71 | <Pressable | 148 | style={{ |
| 72 | className="bg-subtle h-12 w-12 items-center justify-center rounded-full" | 149 | flex: 1, |
| 73 | onPress={() => { | 150 | paddingHorizontal: 20, |
| 74 | clearReviewCommentTarget(); | 151 | paddingTop: 8, |
| 75 | router.dismiss(); | 152 | paddingBottom: Math.max(insets.bottom, 18) + 18, |
| 76 | }} | 153 | }} |
| 77 | > | 154 | > |
| 78 | <SymbolView name="xmark" size={18} tintColor={iconTint} type="monochrome" /> | 155 | <View className="flex-row items-center justify-between py-2"> |
| 79 | </Pressable> | 156 | <Pressable |
| 80 | 157 | className="bg-subtle h-12 w-12 items-center justify-center rounded-full" | |
| 81 | <Text className="text-[18px] font-t3-bold text-foreground">Add Comment</Text> | 158 | onPress={() => { |
| 82 | 159 | clearReviewCommentTarget(); | |
| 83 | <Pressable | 160 | router.dismiss(); |
| 84 | className={cn( | 161 | }} |
| 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 | )} | ||
| 109 | > | 162 | > |
| 110 | Comment | 163 | <SymbolView name="xmark" size={18} tintColor={iconTint} type="monochrome" /> |
| 111 | </Text> | 164 | </Pressable> |
| 112 | </Pressable> | ||
| 113 | </View> | ||
| 114 | 165 | ||
| 115 | {!target ? ( | 166 | <Text className="text-[18px] font-t3-bold text-foreground">Add Comment</Text> |
| 116 | <View className="rounded-[22px] border border-border bg-card px-4 py-5"> | 167 | |
| 117 | <Text className="text-[15px] font-t3-bold text-foreground">No selection</Text> | 168 | <View className="h-12 w-12" /> |
| 118 | <Text className="mt-1 text-[13px] leading-[19px] text-foreground-muted"> | ||
| 119 | Select a diff line or range first. | ||
| 120 | </Text> | ||
| 121 | </View> | 169 | </View> |
| 122 | ) : ( | 170 | |
| 123 | <> | 171 | {!target ? ( |
| 124 | <View className="gap-1 rounded-[22px] border border-border bg-card px-4 py-4"> | 172 | <View className="rounded-[22px] border border-border bg-card px-4 py-5"> |
| 125 | <Text className="font-mono text-[13px] leading-[18px] text-foreground"> | 173 | <Text className="text-[15px] font-t3-bold text-foreground">No selection</Text> |
| 126 | {target.filePath} | 174 | <Text className="mt-1 text-[13px] leading-[19px] text-foreground-muted"> |
| 127 | </Text> | 175 | Select a diff line or range first. |
| 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`} | ||
| 136 | </Text> | 176 | </Text> |
| 137 | </View> | 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"> | 193 | <View className="overflow-hidden rounded-[22px] border border-border bg-card"> |
| 140 | {selectedLines.map((line) => { | 194 | <ScrollView |
| 141 | const lineNumber = getReviewUnifiedLineNumber(line); | 195 | horizontal |
| 142 | 196 | bounces={false} | |
| 143 | return ( | 197 | keyboardShouldPersistTaps="always" |
| 144 | <View | 198 | showsHorizontalScrollIndicator={false} |
| 145 | key={line.id} | 199 | > |
| 146 | className={cn( | 200 | <ScrollView |
| 147 | "flex-row items-start border-b border-border/60", | 201 | bounces={false} |
| 148 | changeTone(line.change), | 202 | scrollEnabled={selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES} |
| 149 | )} | 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"> | 210 | <View style={{ minWidth: previewViewportWidth }}> |
| 152 | {lineNumber ?? ""} | 211 | {selectedLines.map((line) => { |
| 153 | </Text> | 212 | const lineNumber = getReviewUnifiedLineNumber(line); |
| 154 | <Text | 213 | |
| 155 | className="px-0.5 py-2 text-center font-mono text-[12px] text-foreground-muted" | 214 | return ( |
| 156 | style={{ width: 18 }} | 215 | <View |
| 157 | > | 216 | key={line.id} |
| 158 | {getReviewChangeMarker(line.change)} | 217 | className={cn("flex-row items-start", changeTone(line.change))} |
| 159 | </Text> | 218 | style={{ height: REVIEW_COMMENT_LINE_HEIGHT }} |
| 160 | <NativeText | 219 | > |
| 161 | selectable | 220 | <Text className="w-9 px-1 py-1 text-right text-[11px] font-t3-medium text-foreground-muted"> |
| 162 | className="flex-1 px-1 py-2 text-[12px] leading-[19px] text-foreground" | 221 | {lineNumber ?? ""} |
| 163 | style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} | 222 | </Text> |
| 164 | > | 223 | <Text |
| 165 | {renderVisibleWhitespace(line.content || " ")} | 224 | className="px-0.5 py-1 text-center font-mono text-[12px] text-foreground-muted" |
| 166 | </NativeText> | 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 | </View> | 261 | </View> |
| 168 | ); | 262 | {attachments.length > 0 ? ( |
| 169 | })} | 263 | <View className="px-4 pb-3 pt-2"> |
| 170 | </View> | 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"> | 281 | <View className="flex-row items-center gap-3 pt-1"> |
| 173 | <Text className="text-[13px] font-t3-bold text-foreground">Comment</Text> | 282 | <ControlPill icon="plus" onPress={() => void handlePickImages()} /> |
| 174 | <TextInput | 283 | <View className="flex-1" /> |
| 175 | autoFocus | 284 | <ControlPill |
| 176 | multiline | 285 | icon="arrow.up" |
| 177 | placeholder="Leave a comment..." | 286 | label="Comment" |
| 178 | textAlignVertical="top" | 287 | variant="primary" |
| 179 | value={commentText} | 288 | disabled={!canSubmit} |
| 180 | onChangeText={setCommentText} | 289 | onPress={() => { |
| 181 | className="min-h-[192px] rounded-[20px] px-4 py-3.5 font-sans text-[15px]" | 290 | if (!target || !environmentId || !threadId || commentText.trim().length === 0) { |
| 182 | /> | 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 | </View> | 306 | </View> |
| 184 | </> | 307 | )} |
| 185 | )} | 308 | </View> |
| 186 | </ScrollView> | 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 { | 1 | import { ThreadId, type OrchestrationCheckpointSummary } from "@t3tools/contracts"; |
| 2 | ThreadId, | 2 | import { LegendList } from "@legendapp/list/react-native"; |
| 3 | type GitReviewDiffSection, | ||
| 4 | type OrchestrationCheckpointSummary, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { useLocalSearchParams, useRouter } from "expo-router"; | 3 | import { useLocalSearchParams, useRouter } from "expo-router"; |
| 7 | import Stack from "expo-router/stack"; | 4 | import Stack from "expo-router/stack"; |
| 8 | import { SymbolView } from "expo-symbols"; | 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 | import { | 7 | import { |
| 11 | ActivityIndicator, | 8 | ActivityIndicator, |
| 12 | Platform, | ||
| 13 | Pressable, | 9 | Pressable, |
| 14 | ScrollView, | 10 | ScrollView, |
| 15 | Text as NativeText, | 11 | Text as NativeText, |
| @@ -25,16 +21,28 @@ import { useThemeColor } from "../../../lib/useThemeColor"; · 9 unmodified lines | |||
| 25 | import { getEnvironmentClient } from "../../../state/use-remote-environment-registry"; | 21 | import { getEnvironmentClient } from "../../../state/use-remote-environment-registry"; |
| 26 | import { useSelectedThreadDetail } from "../../../state/use-thread-detail"; | 22 | import { useSelectedThreadDetail } from "../../../state/use-thread-detail"; |
| 27 | import { useThreadSelection } from "../../../state/use-thread-selection"; | 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 | import { | 34 | import { |
| 29 | buildReviewParsedDiff, | ||
| 30 | getReadyReviewCheckpoints, | 35 | getReadyReviewCheckpoints, |
| 31 | buildReviewSectionItems, | 36 | buildReviewSectionItems, |
| 32 | getDefaultReviewSectionId, | 37 | getDefaultReviewSectionId, |
| 38 | getReviewFilePreviewState, | ||
| 33 | getReviewSectionIdForCheckpoint, | 39 | getReviewSectionIdForCheckpoint, |
| 40 | type ReviewParsedDiff, | ||
| 34 | type ReviewRenderableFile, | 41 | type ReviewRenderableFile, |
| 35 | type ReviewRenderableLineRow, | 42 | type ReviewRenderableLineRow, |
| 36 | } from "./reviewModel"; | 43 | } from "./reviewModel"; |
| 37 | import { | 44 | import { |
| 45 | getCachedHighlightedReviewFile, | ||
| 38 | highlightReviewFile, | 46 | highlightReviewFile, |
| 39 | type ReviewDiffTheme, | 47 | type ReviewDiffTheme, |
| 40 | type ReviewHighlightedFile, | 48 | type ReviewHighlightedFile, |
| @@ -43,6 +51,7 @@ import { · 2 unmodified lines | |||
| 43 | import { | 51 | import { |
| 44 | buildReviewCommentTarget, | 52 | buildReviewCommentTarget, |
| 45 | clearReviewCommentTarget, | 53 | clearReviewCommentTarget, |
| 54 | countReviewCommentContexts, | ||
| 46 | formatReviewSelectedRangeLabel, | 55 | formatReviewSelectedRangeLabel, |
| 47 | getReviewChangeMarker, | 56 | getReviewChangeMarker, |
| 48 | getReviewUnifiedLineNumber, | 57 | getReviewUnifiedLineNumber, |
| @@ -51,6 +60,7 @@ import { · 2 unmodified lines | |||
| 51 | type ReviewCommentTarget, | 60 | type ReviewCommentTarget, |
| 52 | useReviewCommentTarget, | 61 | useReviewCommentTarget, |
| 53 | } from "./reviewCommentSelection"; | 62 | } from "./reviewCommentSelection"; |
| 63 | import { changeTone, DiffTokenText } from "./reviewDiffRendering"; | ||
| 54 | 64 | ||
| 55 | interface PendingCommentSelection { | 65 | interface PendingCommentSelection { |
| 56 | readonly sectionTitle: string; | 66 | readonly sectionTitle: string; |
| @@ -59,20 +69,16 @@ interface PendingCommentSelection { · 2 unmodified lines | |||
| 59 | readonly anchorIndex: number; | 69 | readonly anchorIndex: number; |
| 60 | } | 70 | } |
| 61 | 71 | ||
| 62 | const REVIEW_MONO_FONT_FAMILY = Platform.select({ | 72 | interface ReviewLineActionInput { |
| 63 | ios: "ui-monospace", | 73 | readonly sectionTitle: string; |
| 64 | android: "monospace", | 74 | readonly filePath: string; |
| 65 | default: "monospace", | 75 | readonly lines: ReadonlyArray<ReviewRenderableLineRow>; |
| 66 | }); | 76 | readonly lineIndex: number; |
| 77 | } | ||
| 78 | |||
| 67 | const IOS_NAV_BAR_HEIGHT = 44; | 79 | const IOS_NAV_BAR_HEIGHT = 44; |
| 68 | const REVIEW_HEADER_SPACING = 32; | 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 | function changeTypeLabel(type: ReviewRenderableFile["changeType"]): string { | 82 | function changeTypeLabel(type: ReviewRenderableFile["changeType"]): string { |
| 77 | switch (type) { | 83 | switch (type) { |
| 78 | case "new": | 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 | readonly additions: string | null; | 98 | readonly additions: string | null; |
| 93 | readonly deletions: string | null; | 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 | function getDefaultExpandedFileIds( | 111 | function getDefaultExpandedFileIds( |
| 110 | files: ReadonlyArray<ReviewRenderableFile>, | 112 | files: ReadonlyArray<ReviewRenderableFile>, |
| 111 | ): ReadonlyArray<string> { | 113 | ): ReadonlyArray<string> { |
| 112 | const autoExpanded = files.filter(shouldAutoExpandFile).map((file) => file.id); | 114 | return files.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 | ); | ||
| 180 | } | 115 | } |
| 181 | 116 | ||
| 182 | function ReviewLineRow(props: { | 117 | const ReviewLineRow = memo(function ReviewLineRow(props: { |
| 183 | readonly line: ReviewRenderableLineRow; | 118 | readonly line: ReviewRenderableLineRow; |
| 184 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; | 119 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; |
| 185 | readonly viewportWidth: number; | 120 | readonly viewportWidth: number; |
| @@ -192,7 +127,7 @@ function ReviewLineRow(props: { · 6 unmodified lines | |||
| 192 | return ( | 127 | return ( |
| 193 | <Pressable | 128 | <Pressable |
| 194 | className={cn( | 129 | className={cn( |
| 195 | "flex-row items-start border-b border-border/60", | 130 | "flex-row items-start", |
| 196 | changeTone(props.line.change), | 131 | changeTone(props.line.change), |
| 197 | props.selectionState === "anchor" && "bg-sky-500/16", | 132 | props.selectionState === "anchor" && "bg-sky-500/16", |
| 198 | props.selectionState === "selected" && "bg-amber-300/28", | 133 | props.selectionState === "selected" && "bg-amber-300/28", |
| @@ -210,30 +145,34 @@ function ReviewLineRow(props: { · 11 unmodified lines | |||
| 210 | onPress={props.onComment} | 145 | onPress={props.onComment} |
| 211 | style={{ minWidth: props.viewportWidth }} | 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 | {lineNumber ?? ""} | 149 | {lineNumber ?? ""} |
| 215 | </Text> | 150 | </Text> |
| 216 | <Text | 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 | style={{ width: 18 }} | 153 | style={{ width: 18 }} |
| 219 | > | 154 | > |
| 220 | {getReviewChangeMarker(props.line.change)} | 155 | {getReviewChangeMarker(props.line.change)} |
| 221 | </Text> | 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 | <DiffTokenText tokens={props.tokens} fallback={props.line.content} /> | 158 | <DiffTokenText tokens={props.tokens} fallback={props.line.content} /> |
| 224 | </View> | 159 | </View> |
| 225 | </Pressable> | 160 | </Pressable> |
| 226 | ); | 161 | ); |
| 227 | } | 162 | }); |
| 228 | 163 | ||
| 229 | function ReviewFileCard(props: { | 164 | const ReviewFileCard = memo(function ReviewFileCard(props: { |
| 230 | readonly file: ReviewRenderableFile; | 165 | readonly file: ReviewRenderableFile; |
| 166 | readonly fileId: string; | ||
| 231 | readonly expanded: boolean; | 167 | readonly expanded: boolean; |
| 232 | readonly onToggle: () => void; | 168 | readonly onToggleFile: (fileId: string) => void; |
| 233 | }) { | 169 | }) { |
| 234 | return ( | 170 | return ( |
| 235 | <View className="border-b border-border bg-card" style={{ zIndex: 1 }}> | 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 | <View className="pt-0.5"> | 176 | <View className="pt-0.5"> |
| 238 | <SymbolView | 177 | <SymbolView |
| 239 | name={props.expanded ? "chevron.down" : "chevron.right"} | 178 | name={props.expanded ? "chevron.down" : "chevron.right"} |
| @@ -266,41 +205,44 @@ function ReviewFileCard(props: { · 26 unmodified lines | |||
| 266 | </Pressable> | 205 | </Pressable> |
| 267 | </View> | 206 | </View> |
| 268 | ); | 207 | ); |
| 269 | } | 208 | }); |
| 270 | 209 | ||
| 271 | function ReviewFileBody(props: { | 210 | const ReviewFileBody = memo(function ReviewFileBody(props: { |
| 272 | readonly file: ReviewRenderableFile; | 211 | readonly file: ReviewRenderableFile; |
| 273 | readonly sectionTitle: string; | 212 | readonly sectionTitle: string; |
| 274 | readonly highlightedFile: ReviewHighlightedFile | null; | 213 | readonly highlightedFile: ReviewHighlightedFile | null; |
| 275 | readonly viewportWidth: number; | 214 | readonly viewportWidth: number; |
| 276 | readonly pendingSelection: PendingCommentSelection | null; | 215 | readonly pendingSelection: PendingCommentSelection | null; |
| 277 | readonly selectedTarget: ReviewCommentTarget | null; | 216 | readonly selectedTarget: ReviewCommentTarget | null; |
| 278 | readonly onPressLine: ( | 217 | readonly onPressLine: (input: ReviewLineActionInput) => void; |
| 279 | line: ReviewRenderableLineRow, | 218 | readonly onStartRangeSelection: (input: ReviewLineActionInput) => void; |
| 280 | availableLines: ReadonlyArray<ReviewRenderableLineRow>, | ||
| 281 | lineIndex: number, | ||
| 282 | ) => void; | ||
| 283 | readonly onStartRangeSelection: ( | ||
| 284 | line: ReviewRenderableLineRow, | ||
| 285 | availableLines: ReadonlyArray<ReviewRenderableLineRow>, | ||
| 286 | lineIndex: number, | ||
| 287 | ) => void; | ||
| 288 | }) { | 219 | }) { |
| 289 | const commentableLines = props.file.rows.filter( | 220 | const commentableLines = useMemo( |
| 290 | (row): row is ReviewRenderableLineRow => row.kind === "line", | 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 | return ( | 247 | return ( |
| 306 | <ScrollView | 248 | <ScrollView |
| @@ -345,16 +287,20 @@ function ReviewFileBody(props: { · 38 unmodified lines | |||
| 345 | : null | 287 | : null |
| 346 | } | 288 | } |
| 347 | onComment={() => { | 289 | onComment={() => { |
| 348 | const lineIndex = commentableLines.findIndex( | 290 | props.onPressLine({ |
| 349 | (candidate) => candidate.id === row.id, | 291 | sectionTitle: props.sectionTitle, |
| 350 | ); | 292 | filePath: props.file.path, |
| 351 | props.onPressLine(row, commentableLines, lineIndex >= 0 ? lineIndex : 0); | 293 | lines: commentableLines, |
| 294 | lineIndex: lineIndexById.get(row.id) ?? 0, | ||
| 295 | }); | ||
| 352 | }} | 296 | }} |
| 353 | onStartRangeSelection={() => { | 297 | onStartRangeSelection={() => { |
| 354 | const lineIndex = commentableLines.findIndex( | 298 | props.onStartRangeSelection({ |
| 355 | (candidate) => candidate.id === row.id, | 299 | sectionTitle: props.sectionTitle, |
| 356 | ); | 300 | filePath: props.file.path, |
| 357 | props.onStartRangeSelection(row, commentableLines, lineIndex >= 0 ? lineIndex : 0); | 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 | </View> | 308 | </View> |
| 363 | </ScrollView> | 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 | function ReviewSelectionActionBar(props: { | 435 | function ReviewSelectionActionBar(props: { |
| 368 | readonly target: ReviewCommentTarget | null; | 436 | readonly target: ReviewCommentTarget | null; |
| @@ -419,20 +487,13 @@ export function ReviewSheet() { · 50 unmodified lines | |||
| 419 | environmentId: string; | 487 | environmentId: string; |
| 420 | threadId: string; | 488 | threadId: string; |
| 421 | }>(); | 489 | }>(); |
| 490 | const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); | ||
| 491 | const reviewCache = useReviewCacheForThread({ environmentId, threadId }); | ||
| 422 | const { selectedThreadProject } = useThreadSelection(); | 492 | const { selectedThreadProject } = useThreadSelection(); |
| 423 | const selectedThread = useSelectedThreadDetail(); | 493 | const selectedThread = useSelectedThreadDetail(); |
| 424 | const [gitSections, setGitSections] = useState<ReadonlyArray<GitReviewDiffSection>>([]); | ||
| 425 | const [turnDiffById, setTurnDiffById] = useState<Record<string, string>>({}); | ||
| 426 | const [loadingTurnIds, setLoadingTurnIds] = useState<Record<string, boolean>>({}); | 494 | const [loadingTurnIds, setLoadingTurnIds] = useState<Record<string, boolean>>({}); |
| 427 | const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null); | ||
| 428 | const [loadingGitDiffs, setLoadingGitDiffs] = useState(false); | 495 | const [loadingGitDiffs, setLoadingGitDiffs] = useState(false); |
| 429 | const [error, setError] = useState<string | null>(null); | 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 | const [pendingCommentSelection, setPendingCommentSelection] = | 497 | const [pendingCommentSelection, setPendingCommentSelection] = |
| 437 | useState<PendingCommentSelection | null>(null); | 498 | useState<PendingCommentSelection | null>(null); |
| 438 | const activeCommentTarget = useReviewCommentTarget(); | 499 | const activeCommentTarget = useReviewCommentTarget(); |
| @@ -456,39 +517,49 @@ export function ReviewSheet() { · 17 unmodified lines | |||
| 456 | () => | 517 | () => |
| 457 | buildReviewSectionItems({ | 518 | buildReviewSectionItems({ |
| 458 | checkpoints: readyCheckpoints, | 519 | checkpoints: readyCheckpoints, |
| 459 | gitSections, | 520 | gitSections: reviewCache.gitSections, |
| 460 | turnDiffById, | 521 | turnDiffById: reviewCache.turnDiffById, |
| 461 | loadingTurnIds, | 522 | loadingTurnIds, |
| 462 | }), | 523 | }), |
| 463 | [gitSections, loadingTurnIds, readyCheckpoints, turnDiffById], | 524 | [loadingTurnIds, readyCheckpoints, reviewCache.gitSections, reviewCache.turnDiffById], |
| 464 | ); | 525 | ); |
| 465 | 526 | ||
| 466 | const selectedSection = | 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 | const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; | 531 | const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; |
| 469 | const parsedDiff = useMemo( | 532 | const parsedDiff = useMemo( |
| 470 | () => buildReviewParsedDiff(selectedSection?.diff, selectedSection?.id ?? "mobile-review"), | 533 | () => |
| 471 | [selectedSection?.diff, selectedSection?.id], | 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 | const headerDiffSummary = useMemo(() => formatHeaderDiffSummary(parsedDiff), [parsedDiff]); | 541 | const headerDiffSummary = useMemo(() => formatHeaderDiffSummary(parsedDiff), [parsedDiff]); |
| 542 | const pendingReviewCommentCount = useMemo( | ||
| 543 | () => countReviewCommentContexts(draftMessage), | ||
| 544 | [draftMessage], | ||
| 545 | ); | ||
| 474 | 546 | ||
| 475 | const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme; | 547 | const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme; |
| 476 | const expandedFileIds = useMemo( | 548 | const expandedFileIds = useMemo( |
| 477 | () => | 549 | () => |
| 478 | selectedSection?.id && parsedDiff.kind === "files" | 550 | selectedSection?.id && parsedDiff.kind === "files" |
| 479 | ? (expandedFileIdsBySection[selectedSection.id] ?? | 551 | ? (reviewCache.expandedFileIdsBySection[selectedSection.id] ?? |
| 480 | getDefaultExpandedFileIds(parsedDiff.files)) | 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" | 558 | selectedSection?.id |
| 487 | ? parsedDiff.files.filter((file) => expandedFileIds.includes(file.id)) | 559 | ? (reviewCache.revealedLargeFileIdsBySection[selectedSection.id] ?? []) |
| 488 | : [], | 560 | : [], |
| 489 | [expandedFileIds, parsedDiff], | 561 | [reviewCache.revealedLargeFileIdsBySection, selectedSection?.id], |
| 490 | ); | 562 | ); |
| 491 | |||
| 492 | const loadGitDiffs = useCallback(async () => { | 563 | const loadGitDiffs = useCallback(async () => { |
| 493 | if (!environmentId || !cwd) { | 564 | if (!environmentId || !cwd) { |
| 494 | return; | 565 | return; |
| @@ -504,13 +575,15 @@ export function ReviewSheet() { · 9 unmodified lines | |||
| 504 | setError(null); | 575 | setError(null); |
| 505 | try { | 576 | try { |
| 506 | const result = await client.git.getReviewDiffs({ cwd }); | 577 | const result = await client.git.getReviewDiffs({ cwd }); |
| 507 | setGitSections(result.sections); | 578 | if (reviewCache.threadKey) { |
| 579 | setReviewGitSections(reviewCache.threadKey, result.sections); | ||
| 580 | } | ||
| 508 | } catch (cause) { | 581 | } catch (cause) { |
| 509 | setError(cause instanceof Error ? cause.message : "Failed to load review diffs."); | 582 | setError(cause instanceof Error ? cause.message : "Failed to load review diffs."); |
| 510 | } finally { | 583 | } finally { |
| 511 | setLoadingGitDiffs(false); | 584 | setLoadingGitDiffs(false); |
| 512 | } | 585 | } |
| 513 | }, [cwd, environmentId]); | 586 | }, [cwd, environmentId, reviewCache.threadKey]); |
| 514 | 587 | ||
| 515 | const loadTurnDiff = useCallback( | 588 | const loadTurnDiff = useCallback( |
| 516 | async (checkpoint: OrchestrationCheckpointSummary, force = false) => { | 589 | async (checkpoint: OrchestrationCheckpointSummary, force = false) => { |
| @@ -519,9 +592,11 @@ export function ReviewSheet() { · 2 unmodified lines | |||
| 519 | } | 592 | } |
| 520 | 593 | ||
| 521 | const sectionId = getReviewSectionIdForCheckpoint(checkpoint); | 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 | return; | 600 | return; |
| 526 | } | 601 | } |
| 527 | 602 | ||
| @@ -539,7 +614,9 @@ export function ReviewSheet() { · 11 unmodified lines | |||
| 539 | fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1), | 614 | fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1), |
| 540 | toTurnCount: checkpoint.checkpointTurnCount, | 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 | } catch (cause) { | 620 | } catch (cause) { |
| 544 | setError(cause instanceof Error ? cause.message : "Failed to load turn diff."); | 621 | setError(cause instanceof Error ? cause.message : "Failed to load turn diff."); |
| 545 | } finally { | 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 | useEffect(() => { | 633 | useEffect(() => { |
| @@ -563,10 +640,14 @@ export function ReviewSheet() { · 6 unmodified lines | |||
| 563 | } | 640 | } |
| 564 | 641 | ||
| 565 | const fallbackId = getDefaultReviewSectionId(reviewSections); | 642 | const fallbackId = getDefaultReviewSectionId(reviewSections); |
| 566 | if (!selectedSectionId || !reviewSections.some((section) => section.id === selectedSectionId)) { | 643 | if ( |
| 567 | setSelectedSectionId(fallbackId); | 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 | useEffect(() => { | 652 | useEffect(() => { |
| 572 | const latest = readyCheckpoints[0]; | 653 | const latest = readyCheckpoints[0]; |
| @@ -575,12 +656,12 @@ export function ReviewSheet() { · 2 unmodified lines | |||
| 575 | } | 656 | } |
| 576 | 657 | ||
| 577 | const latestId = getReviewSectionIdForCheckpoint(latest); | 658 | const latestId = getReviewSectionIdForCheckpoint(latest); |
| 578 | if (turnDiffById[latestId] !== undefined || loadingTurnIds[latestId]) { | 659 | if (reviewCache.turnDiffById[latestId] !== undefined || loadingTurnIds[latestId]) { |
| 579 | return; | 660 | return; |
| 580 | } | 661 | } |
| 581 | 662 | ||
| 582 | void loadTurnDiff(latest); | 663 | void loadTurnDiff(latest); |
| 583 | }, [loadTurnDiff, loadingTurnIds, readyCheckpoints, turnDiffById]); | 664 | }, [loadTurnDiff, loadingTurnIds, readyCheckpoints, reviewCache.turnDiffById]); |
| 584 | 665 | ||
| 585 | useEffect(() => { | 666 | useEffect(() => { |
| 586 | if (!selectedSection || selectedSection.kind !== "turn" || selectedSection.diff !== null) { | 667 | if (!selectedSection || selectedSection.kind !== "turn" || selectedSection.diff !== null) { |
| @@ -594,68 +675,41 @@ export function ReviewSheet() { · 7 unmodified lines | |||
| 594 | }, [checkpointBySectionId, loadTurnDiff, loadingTurnIds, selectedSection]); | 675 | }, [checkpointBySectionId, loadTurnDiff, loadingTurnIds, selectedSection]); |
| 595 | 676 | ||
| 596 | useEffect(() => { | 677 | useEffect(() => { |
| 597 | if (!selectedSectionId || parsedDiff.kind !== "files") { | 678 | if (!reviewCache.threadKey || !selectedSection?.id || parsedDiff.kind !== "files") { |
| 598 | return; | 679 | return; |
| 599 | } | 680 | } |
| 600 | 681 | ||
| 601 | setExpandedFileIdsBySection((current) => { | 682 | updateReviewExpandedFileIds(reviewCache.threadKey, selectedSection.id, (existing) => { |
| 602 | const existing = current[selectedSectionId]; | ||
| 603 | if (existing !== undefined) { | 683 | if (existing !== undefined) { |
| 604 | const validIds = existing.filter((id) => parsedDiff.files.some((file) => file.id === id)); | 684 | const validIds = existing.filter((id) => parsedDiff.files.some((file) => file.id === id)); |
| 605 | if (validIds.length === existing.length) { | 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 { | 691 | return getDefaultExpandedFileIds(parsedDiff.files); |
| 612 | ...current, | ||
| 613 | [selectedSectionId]: getDefaultExpandedFileIds(parsedDiff.files), | ||
| 614 | }; | ||
| 615 | }); | 692 | }); |
| 616 | }, [parsedDiff, selectedSectionId]); | 693 | }, [parsedDiff, reviewCache.threadKey, selectedSection?.id]); |
| 617 | 694 | ||
| 618 | useEffect(() => { | 695 | useEffect(() => { |
| 619 | if (!selectedSection || expandedFiles.length === 0) { | 696 | if (!reviewCache.threadKey || !selectedSection?.id || parsedDiff.kind !== "files") { |
| 620 | return; | 697 | return; |
| 621 | } | 698 | } |
| 622 | 699 | ||
| 623 | const filesToHighlight = expandedFiles.filter((file) => { | 700 | updateReviewRevealedLargeFileIds(reviewCache.threadKey, selectedSection.id, (existing) => { |
| 624 | const key = `${selectedSection.id}:${selectedTheme}:${file.cacheKey}`; | 701 | if (existing === undefined) { |
| 625 | return highlightedFileByKey[key] === undefined; | 702 | return undefined; |
| 626 | }); | 703 | } |
| 627 | if (filesToHighlight.length === 0) { | ||
| 628 | return; | ||
| 629 | } | ||
| 630 | 704 | ||
| 631 | let cancelled = false; | 705 | const validIds = existing.filter((id) => parsedDiff.files.some((file) => file.id === id)); |
| 632 | void Promise.all( | 706 | if (validIds.length === existing.length) { |
| 633 | filesToHighlight.map( | 707 | return existing; |
| 634 | async (file) => | 708 | } |
| 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 | }); | ||
| 654 | 709 | ||
| 655 | return () => { | 710 | return validIds; |
| 656 | cancelled = true; | 711 | }); |
| 657 | }; | 712 | }, [parsedDiff, reviewCache.threadKey, selectedSection?.id]); |
| 658 | }, [expandedFiles, highlightedFileByKey, selectedSection, selectedTheme]); | ||
| 659 | 713 | ||
| 660 | const refreshSelectedSection = useCallback(async () => { | 714 | const refreshSelectedSection = useCallback(async () => { |
| 661 | if (!selectedSection) { | 715 | if (!selectedSection) { |
| @@ -673,9 +727,100 @@ export function ReviewSheet() { · 11 unmodified lines | |||
| 673 | await loadGitDiffs(); | 727 | await loadGitDiffs(); |
| 674 | }, [checkpointBySectionId, loadGitDiffs, loadTurnDiff, selectedSection]); | 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 | const children: ReactElement[] = []; | 823 | const children: ReactElement[] = []; |
| 678 | const stickyHeaderIndices: number[] = []; | ||
| 679 | 824 | ||
| 680 | if (error) { | 825 | if (error) { |
| 681 | children.push( | 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) { | 834 | if (parsedDiffNotice) { |
| 690 | children.push( | 835 | children.push(<ReviewNotice key="review-notice" notice={parsedDiffNotice} />); |
| 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 }; | ||
| 715 | } | 836 | } |
| 716 | 837 | ||
| 717 | if (selectedSection.isLoading && selectedSection.diff === null) { | 838 | if (children.length === 0) { |
| 718 | children.push( | 839 | return null; |
| 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 }; | ||
| 728 | } | 840 | } |
| 729 | 841 | ||
| 730 | if (parsedDiff.kind === "empty") { | 842 | return <>{children}</>; |
| 731 | children.push( | 843 | }, [error, parsedDiffNotice]); |
| 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 | } | ||
| 741 | 844 | ||
| 742 | if (parsedDiff.kind === "raw") { | 845 | const renderFileItem = useCallback( |
| 743 | children.push( | 846 | ({ item }: { item: ReviewRenderableFile; index: number }) => { |
| 744 | <View key="review-raw" className="gap-3 border-b border-border bg-card px-4 py-4"> | 847 | if (!selectedSection) { |
| 745 | <Text className="text-[12px] leading-[18px] text-foreground-muted"> | 848 | return null; |
| 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 | ); | ||
| 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 | activeCommentTarget, | 906 | activeCommentTarget, |
| 857 | error, | ||
| 858 | expandedFileIds, | 907 | expandedFileIds, |
| 859 | highlightedFileByKey, | ||
| 860 | pendingCommentSelection, | 908 | pendingCommentSelection, |
| 861 | parsedDiff, | 909 | revealedLargeFileIds, |
| 862 | router, | ||
| 863 | selectedSection, | 910 | selectedSection, |
| 864 | selectedTheme, | 911 | selectedTheme, |
| 865 | threadId, | ||
| 866 | width, | 912 | width, |
| 867 | environmentId, | ||
| 868 | ]); | 913 | ]); |
| 869 | 914 | ||
| 870 | return ( | 915 | return ( |
| @@ -891,7 +936,15 @@ export function ReviewSheet() { · 20 unmodified lines | |||
| 891 | > | 936 | > |
| 892 | Files Changed | 937 | Files Changed |
| 893 | </NativeText> | 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 | {headerDiffSummary.additions && headerDiffSummary.deletions ? ( | 948 | {headerDiffSummary.additions && headerDiffSummary.deletions ? ( |
| 896 | <> | 949 | <> |
| 897 | <NativeText | 950 | <NativeText |
| @@ -914,19 +967,45 @@ export function ReviewSheet() { · 16 unmodified lines | |||
| 914 | > | 967 | > |
| 915 | {headerDiffSummary.deletions} | 968 | {headerDiffSummary.deletions} |
| 916 | </NativeText> | 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 | 984 | <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}> |
| 920 | numberOfLines={1} | 985 | <NativeText |
| 921 | style={{ | 986 | numberOfLines={1} |
| 922 | fontFamily: "DMSans_700Bold", | 987 | style={{ |
| 923 | fontSize: 12, | 988 | fontFamily: "DMSans_700Bold", |
| 924 | fontWeight: "700", | 989 | fontSize: 12, |
| 925 | color: headerMuted, | 990 | fontWeight: "700", |
| 926 | }} | 991 | color: headerMuted, |
| 927 | > | 992 | }} |
| 928 | {selectedSection?.title ?? "Review changes"} | 993 | > |
| 929 | </NativeText> | 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 | </View> | 1010 | </View> |
| 932 | </View> | 1011 | </View> |
| @@ -940,7 +1019,11 @@ export function ReviewSheet() { · 7 unmodified lines | |||
| 940 | <Stack.Toolbar.MenuAction | 1019 | <Stack.Toolbar.MenuAction |
| 941 | key={section.id} | 1020 | key={section.id} |
| 942 | icon={section.id === selectedSection?.id ? "checkmark" : "circle"} | 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 | subtitle={section.subtitle ?? undefined} | 1027 | subtitle={section.subtitle ?? undefined} |
| 945 | > | 1028 | > |
| 946 | <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label> | 1029 | <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label> |
| @@ -961,22 +1044,73 @@ export function ReviewSheet() { · 14 unmodified lines | |||
| 961 | </Stack.Toolbar> | 1044 | </Stack.Toolbar> |
| 962 | 1045 | ||
| 963 | <View className="flex-1 bg-sheet"> | 1046 | <View className="flex-1 bg-sheet"> |
| 964 | <ScrollView | 1047 | {selectedSection && parsedDiff.kind === "files" ? ( |
| 965 | contentInsetAdjustmentBehavior="never" | 1048 | <LegendList |
| 966 | contentInset={{ top: topContentInset }} | 1049 | style={{ flex: 1 }} |
| 967 | contentOffset={{ x: 0, y: -topContentInset }} | 1050 | contentInsetAdjustmentBehavior="never" |
| 968 | scrollIndicatorInsets={{ top: topContentInset }} | 1051 | contentInset={{ top: topContentInset }} |
| 969 | showsVerticalScrollIndicator={false} | 1052 | scrollIndicatorInsets={{ top: topContentInset }} |
| 970 | style={{ flex: 1 }} | 1053 | data={parsedDiff.files as ReviewRenderableFile[]} |
| 971 | stickyHeaderIndices={reviewContent.stickyHeaderIndices} | 1054 | renderItem={renderFileItem} |
| 972 | stickyHeaderHiddenOnScroll={false} | 1055 | keyExtractor={(file) => file.id} |
| 973 | contentContainerStyle={{ | 1056 | extraData={visibleListExtraData} |
| 974 | paddingTop: REVIEW_HEADER_SPACING, | 1057 | keyboardShouldPersistTaps="handled" |
| 975 | paddingBottom: Math.max(insets.bottom, 18) + 18, | 1058 | estimatedItemSize={220} |
| 976 | }} | 1059 | drawDistance={900} |
| 977 | > | 1060 | recycleItems |
| 978 | {reviewContent.children} | 1061 | ListHeaderComponent={listHeader} |
| 979 | </ScrollView> | 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 | <ReviewSelectionActionBar | 1115 | <ReviewSelectionActionBar |
| 982 | target={activeCommentTarget} | 1116 | target={activeCommentTarget} |
| @@ -152,3 +152,7 @@ export function formatReviewCommentContext(target: ReviewCommentTarget, comment: · 151 unmodified lines | |||
| 152 | "</review_comment>", | 152 | "</review_comment>", |
| 153 | ].join("\n"); | 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 | buildReviewParsedDiff, | 11 | buildReviewParsedDiff, |
| 12 | buildReviewSectionItems, | 12 | buildReviewSectionItems, |
| 13 | getDefaultReviewSectionId, | 13 | getDefaultReviewSectionId, |
| 14 | getReviewFilePreviewState, | ||
| 14 | getReviewSectionIdForCheckpoint, | 15 | getReviewSectionIdForCheckpoint, |
| 16 | type ReviewRenderableFile, | ||
| 15 | } from "./reviewModel"; | 17 | } from "./reviewModel"; |
| 16 | 18 | ||
| 17 | function makeCheckpoint( | 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 | describe("buildReviewSectionItems", () => { | 50 | describe("buildReviewSectionItems", () => { |
| 31 | it("keeps one chip per checkpoint and appends git sources", () => { | 51 | it("keeps one chip per checkpoint and appends git sources", () => { |
| 32 | const checkpoints = [ | 52 | const checkpoints = [ |
| @@ -171,4 +191,44 @@ describe("buildReviewParsedDiff", () => { · 138 unmodified lines | |||
| 171 | header: "@@ -1,1 +1,2 @@", | 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 | readonly rows: ReadonlyArray<ReviewRenderableRow>; | 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 | export type ReviewParsedDiff = | 63 | export type ReviewParsedDiff = |
| 52 | | { | 64 | | { |
| 53 | readonly kind: "empty"; | 65 | readonly kind: "empty"; |
| @@ -147,6 +159,50 @@ const FNV_OFFSET_BASIS_32 = 0x811c9dc5; · 93 unmodified lines | |||
| 147 | const FNV_PRIME_32 = 0x01000193; | 159 | const FNV_PRIME_32 = 0x01000193; |
| 148 | const SECONDARY_HASH_SEED = 0x9e3779b9; | 160 | const SECONDARY_HASH_SEED = 0x9e3779b9; |
| 149 | const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b; | 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 | function fnv1a32(input: string, seed: number, multiplier: number): number { | 207 | function fnv1a32(input: string, seed: number, multiplier: number): number { |
| 152 | let hash = seed >>> 0; | 208 | let hash = seed >>> 0; |
| @@ -168,6 +224,49 @@ function buildPatchCacheKey(patch: string, scope: string): string { · 15 unmodified lines | |||
| 168 | return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`; | 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 | function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { | 270 | function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { |
| 172 | return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`; | 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 | import { getFiletypeFromFileName } from "@pierre/diffs/utils"; | 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 | export type ReviewDiffTheme = "light" | "dark"; | 11 | export type ReviewDiffTheme = "light" | "dark"; |
| 6 | 12 | ||
| @@ -19,8 +25,12 @@ const SHIKI_THEME_BY_SCHEME = { · 12 unmodified lines | |||
| 19 | light: "github-light-default", | 25 | light: "github-light-default", |
| 20 | dark: "github-dark-default", | 26 | dark: "github-dark-default", |
| 21 | } as const; | 27 | } as const; |
| 28 | const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( | ||
| 29 | process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE, | ||
| 30 | ); | ||
| 22 | 31 | ||
| 23 | const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>(); | 32 | const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>(); |
| 33 | const resolvedHighlightCache = new Map<string, ReviewHighlightedFile>(); | ||
| 24 | const loadedLanguages = new Set<string>(["text"]); | 34 | const loadedLanguages = new Set<string>(["text"]); |
| 25 | type ShikiHighlighter = { | 35 | type ShikiHighlighter = { |
| 26 | loadLanguage: (...langs: string[]) => Promise<void>; | 36 | loadLanguage: (...langs: string[]) => Promise<void>; |
| @@ -30,38 +40,83 @@ type ShikiHighlighter = { · 3 unmodified lines | |||
| 30 | ) => Promise<Array<Array<{ content: string; color?: string; fontStyle?: number }>>>; | 40 | ) => Promise<Array<Array<{ content: string; color?: string; fontStyle?: number }>>>; |
| 31 | }; | 41 | }; |
| 32 | let highlighterPromise: Promise<ShikiHighlighter> | null = null; | 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 | return value.endsWith("\n") ? value.slice(0, -1) : value; | 46 | return value.endsWith("\n") ? value.slice(0, -1) : value; |
| 36 | } | 47 | } |
| 37 | 48 | ||
| 38 | function joinPatchLines(lines: ReadonlyArray<string>): string { | 49 | function joinPatchLines(lines: ReadonlyArray<string>): string { |
| 39 | if (lines.length === 0) { | 50 | return lines.map(stripTrailingNewline).join("\n"); |
| 40 | return ""; | ||
| 41 | } | ||
| 42 | |||
| 43 | return cleanLastNewline(lines.join("")); | ||
| 44 | } | 51 | } |
| 45 | 52 | ||
| 46 | async function getHighlighter() { | 53 | async function getHighlighter() { |
| 47 | if (!highlighterPromise) { | 54 | if (!highlighterPromise) { |
| 48 | highlighterPromise = import("shiki").then( | 55 | const configuredHighlighterPromise = import("shiki").then(async (shikiModule) => { |
| 49 | async ({ createHighlighter, createJavaScriptRegexEngine }) => { | 56 | const nativeRuntimeAvailable = hasTurboModuleProxy(); |
| 50 | const highlighter = await createHighlighter({ | 57 | let nativeEngineAvailable = false; |
| 51 | themes: [SHIKI_THEME_BY_SCHEME.light, SHIKI_THEME_BY_SCHEME.dark], | 58 | |
| 52 | langs: [], | 59 | if (nativeRuntimeAvailable && REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE !== "javascript") { |
| 53 | engine: createJavaScriptRegexEngine(), | 60 | try { |
| 54 | }); | 61 | const nativeEngineModule = await import("react-native-shiki-engine"); |
| 55 | return highlighter as unknown as ShikiHighlighter; | 62 | nativeEngineAvailable = nativeEngineModule.isNativeEngineAvailable(); |
| 56 | }, | 63 | if (nativeEngineAvailable) { |
| 57 | ); | 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 | return highlighterPromise as Promise<ShikiHighlighter>; | 106 | return highlighterPromise as Promise<ShikiHighlighter>; |
| 61 | } | 107 | } |
| 62 | 108 | ||
| 63 | async function resolveLanguage(file: ReviewRenderableFile): Promise<string> { | 109 | export async function getActiveReviewHighlighterEngine(): Promise<ReviewHighlighterEngine> { |
| 64 | const candidate = file.languageHint ?? getFiletypeFromFileName(file.path); | 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 | if (!candidate || candidate === "text" || candidate === "ansi") { | 120 | if (!candidate || candidate === "text" || candidate === "ansi") { |
| 66 | return "text"; | 121 | return "text"; |
| 67 | } | 122 | } |
| @@ -79,6 +134,10 @@ async function resolveLanguage(file: ReviewRenderableFile): Promise<string> { · 11 unmodified lines | |||
| 79 | return candidate; | 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 | function normalizeHighlightedLines( | 141 | function normalizeHighlightedLines( |
| 83 | tokenLines: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>, | 142 | tokenLines: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>, |
| 84 | ): ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> { | 143 | ): ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> { |
| @@ -105,12 +164,27 @@ async function highlightLines( · 20 unmodified lines | |||
| 105 | return normalizeHighlightedLines(tokenLines); | 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 | export async function highlightReviewFile( | 178 | export async function highlightReviewFile( |
| 109 | file: ReviewRenderableFile, | 179 | file: ReviewRenderableFile, |
| 110 | theme: ReviewDiffTheme, | 180 | theme: ReviewDiffTheme, |
| 111 | ): Promise<ReviewHighlightedFile> { | 181 | ): Promise<ReviewHighlightedFile> { |
| 112 | const shikiTheme = SHIKI_THEME_BY_SCHEME[theme]; | 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 | const cached = highlightCache.get(cacheKey); | 188 | const cached = highlightCache.get(cacheKey); |
| 115 | if (cached) { | 189 | if (cached) { |
| 116 | return cached; | 190 | return cached; |
| @@ -123,9 +197,52 @@ export async function highlightReviewFile( · 6 unmodified lines | |||
| 123 | highlightLines(joinPatchLines(file.deletionLines), language, shikiTheme), | 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 | highlightCache.set(cacheKey, promise); | 205 | highlightCache.set(cacheKey, promise); |
| 130 | return promise; | 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 | readonly environmentId: string; | 93 | readonly environmentId: string; |
| 94 | readonly threadId: string; | 94 | readonly threadId: string; |
| 95 | readonly text: string; | 95 | readonly text: string; |
| 96 | readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 96 | }): void { | 97 | }): void { |
| 97 | const threadKey = scopedThreadKey( | 98 | const threadKey = scopedThreadKey( |
| 98 | EnvironmentId.make(input.environmentId), | 99 | EnvironmentId.make(input.environmentId), |
| @@ -105,6 +106,26 @@ export function appendReviewCommentToDraft(input: { · 6 unmodified lines | |||
| 105 | ...current, | 106 | ...current, |
| 106 | [threadKey]: `${existing}${separator}${input.text}`, | 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 | function clearDraft(threadKey: string): void { | 131 | function clearDraft(threadKey: string): void { |
| @@ -84,6 +84,7 @@ · 83 unmodified lines | |||
| 84 | "react-native-reanimated": "4.2.1", | 84 | "react-native-reanimated": "4.2.1", |
| 85 | "react-native-safe-area-context": "~5.6.2", | 85 | "react-native-safe-area-context": "~5.6.2", |
| 86 | "react-native-screens": "~4.23.0", | 86 | "react-native-screens": "~4.23.0", |
| 87 | "react-native-shiki-engine": "^0.3.9", | ||
| 87 | "react-native-svg": "15.15.3", | 88 | "react-native-svg": "15.15.3", |
| 88 | "react-native-worklets": "0.7.2", | 89 | "react-native-worklets": "0.7.2", |
| 89 | "shiki": "^4.0.2", | 90 | "shiki": "^4.0.2", |
| @@ -1042,7 +1043,7 @@ · 952 unmodified lines | |||
| 1042 | 1043 | ||
| 1043 | "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], | 1044 | "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], |
| 1044 | 1045 | ||
| 1045 | "@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=="], | 1046 | "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], |
| 1046 | 1047 | ||
| 1047 | "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], | 1048 | "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], |
| 1048 | 1049 | ||
| @@ -2352,6 +2353,8 @@ · 1303 unmodified lines | |||
| 2352 | 2353 | ||
| 2353 | "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=="], | 2354 | "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=="], |
| 2354 | 2355 | ||
| 2356 | "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=="], | ||
| 2357 | |||
| 2355 | "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=="], | 2358 | "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=="], |
| 2356 | 2359 | ||
| 2357 | "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=="], | 2360 | "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=="], |
| @@ -2904,9 +2907,19 @@ · 546 unmodified lines | |||
| 2904 | 2907 | ||
| 2905 | "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], | 2908 | "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], |
| 2906 | 2909 | ||
| 2907 | "@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=="], | 2910 | "@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=="], |
| 2911 | |||
| 2912 | "@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=="], | ||
| 2913 | |||
| 2914 | "@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=="], | ||
| 2915 | |||
| 2916 | "@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=="], | ||
| 2908 | 2917 | ||
| 2909 | "@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=="], | 2918 | "@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=="], |
| 2919 | |||
| 2920 | "@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=="], | ||
| 2921 | |||
| 2922 | "@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=="], | ||
| 2910 | 2923 | ||
| 2911 | "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], | 2924 | "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], |
| 2912 | 2925 | ||
| @@ -3070,6 +3083,8 @@ · 157 unmodified lines | |||
| 3070 | 3083 | ||
| 3071 | "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], | 3084 | "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], |
| 3072 | 3085 | ||
| 3086 | "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=="], | ||
| 3087 | |||
| 3073 | "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], | 3088 | "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], |
| 3074 | 3089 | ||
| 3075 | "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], | 3090 | "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], |
| @@ -3136,8 +3151,6 @@ · 60 unmodified lines | |||
| 3136 | 3151 | ||
| 3137 | "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], | 3152 | "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], |
| 3138 | 3153 | ||
| 3139 | "@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=="], | ||
| 3140 | |||
| 3141 | "@react-native/babel-preset/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], | 3154 | "@react-native/babel-preset/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], |
| 3142 | 3155 | ||
| 3143 | "@react-native/codegen/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], | 3156 | "@react-native/codegen/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], |