| author | |
| committer | |
| log | bed923986fbcbe8279f23b9367efde1596bde1f9 |
| tree | 73d3b93376b48a2861ad36bac945dff203f8c2b0 |
| parent | 5495cc4445915c3bf4aa92bb57213f1c7cc220ef |
| signature | Signed by PGP key B5690EEEBB952194 |
44 files changed, 2097 insertions(+), 710 deletions(-)
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt+3| ... | @@ -41,6 +41,9 @@ class T3ReviewDiffModule : Module() { | ... | @@ -41,6 +41,9 @@ class T3ReviewDiffModule : Module() { |
| 41 | Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double -> | 41 | Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double -> |
| 42 | view.setContentWidth(contentWidth.toFloat()) | 42 | view.setContentWidth(contentWidth.toFloat()) |
| 43 | } | 43 | } |
| 44 | Prop("scrollEnabled") { view: T3ReviewDiffView, enabled: Boolean -> | ||
| 45 | view.setScrollEnabled(enabled) | ||
| 46 | } | ||
| 44 | Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double -> | 47 | Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double -> |
| 45 | view.setInitialRowIndex(initialRowIndex) | 48 | view.setInitialRowIndex(initialRowIndex) |
| 46 | } | 49 | } |
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt+23-10| ... | @@ -50,12 +50,21 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont | ... | @@ -50,12 +50,21 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 50 | private val verticalScroller = OverScroller(context) | 50 | private val verticalScroller = OverScroller(context) |
| 51 | private val horizontalScroller = OverScroller(context) | 51 | private val horizontalScroller = OverScroller(context) |
| 52 | private var dragAxis: DragAxis? = null | 52 | private var dragAxis: DragAxis? = null |
| 53 | private var scrollEnabled = true | ||
| 53 | private var horizontalDragTarget: HorizontalPanTarget? = null | 54 | private var horizontalDragTarget: HorizontalPanTarget? = null |
| 54 | private var lastTouchX = 0f | 55 | private var lastTouchX = 0f |
| 55 | private var lastTouchY = 0f | 56 | private var lastTouchY = 0f |
| 56 | private var velocityTracker: VelocityTracker? = null | 57 | private var velocityTracker: VelocityTracker? = null |
| 57 | 58 | ||
| 58 | init { | 59 | init { |
| 60 | canvasView.onContentHeightChanged = { height -> | ||
| 61 | onDebug( | ||
| 62 | mapOf( | ||
| 63 | "message" to "metrics", | ||
| 64 | "contentHeight" to height / resources.displayMetrics.density | ||
| 65 | ) | ||
| 66 | ) | ||
| 67 | } | ||
| 59 | canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) } | 68 | canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) } |
| 60 | canvasView.onVisibleRowsChanged = { first, last -> | 69 | canvasView.onVisibleRowsChanged = { first, last -> |
| 61 | onDebug( | 70 | onDebug( |
| ... | @@ -198,6 +207,14 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont | ... | @@ -198,6 +207,14 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 198 | scrollToY(canvasView.rowTop(index), animated) | 207 | scrollToY(canvasView.rowTop(index), animated) |
| 199 | } | 208 | } |
| 200 | 209 | ||
| 210 | fun setScrollEnabled(enabled: Boolean) { | ||
| 211 | scrollEnabled = enabled | ||
| 212 | if (!enabled) { | ||
| 213 | verticalScroller.forceFinished(true) | ||
| 214 | canvasView.setVerticalOffset(0) | ||
| 215 | } | ||
| 216 | } | ||
| 217 | |||
| 201 | fun scrollToTop(animated: Boolean) { | 218 | fun scrollToTop(animated: Boolean) { |
| 202 | scrollToY(0, animated) | 219 | scrollToY(0, animated) |
| 203 | } | 220 | } |
| ... | @@ -212,7 +229,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont | ... | @@ -212,7 +229,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 212 | horizontalDragTarget = canvasView.horizontalPanTarget(event.y) | 229 | horizontalDragTarget = canvasView.horizontalPanTarget(event.y) |
| 213 | lastTouchX = event.x | 230 | lastTouchX = event.x |
| 214 | lastTouchY = event.y | 231 | lastTouchY = event.y |
| 215 | parent?.requestDisallowInterceptTouchEvent(true) | 232 | parent?.requestDisallowInterceptTouchEvent(scrollEnabled) |
| 216 | return false | 233 | return false |
| 217 | } | 234 | } |
| 218 | MotionEvent.ACTION_MOVE -> { | 235 | MotionEvent.ACTION_MOVE -> { |
| ... | @@ -221,7 +238,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont | ... | @@ -221,7 +238,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 221 | val deltaY = event.y - lastTouchY | 238 | val deltaY = event.y - lastTouchY |
| 222 | if (max(abs(deltaX), abs(deltaY)) > touchSlop) { | 239 | if (max(abs(deltaX), abs(deltaY)) > touchSlop) { |
| 223 | dragAxis = if (abs(deltaY) >= abs(deltaX)) { | 240 | dragAxis = if (abs(deltaY) >= abs(deltaX)) { |
| 224 | DragAxis.VERTICAL | 241 | if (scrollEnabled) DragAxis.VERTICAL else null |
| 225 | } else { | 242 | } else { |
| 226 | horizontalDragTarget | 243 | horizontalDragTarget |
| 227 | ?.takeIf { canvasView.maxHorizontalOffset(it) > 0 } | 244 | ?.takeIf { canvasView.maxHorizontalOffset(it) > 0 } |
| ... | @@ -229,6 +246,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont | ... | @@ -229,6 +246,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 229 | } | 246 | } |
| 230 | } | 247 | } |
| 231 | } | 248 | } |
| 249 | if (dragAxis == DragAxis.HORIZONTAL) parent?.requestDisallowInterceptTouchEvent(true) | ||
| 232 | return dragAxis != null | 250 | return dragAxis != null |
| 233 | } | 251 | } |
| 234 | MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> return dragAxis != null | 252 | MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> return dragAxis != null |
| ... | @@ -751,6 +769,7 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -751,6 +769,7 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 751 | } | 769 | } |
| 752 | var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null | 770 | var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null |
| 753 | var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null | 771 | var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null |
| 772 | var onContentHeightChanged: ((Int) -> Unit)? = null | ||
| 754 | 773 | ||
| 755 | fun prepareRows() = drawing.prepareRows(tokensByRowId, style, width) | 774 | fun prepareRows() = drawing.prepareRows(tokensByRowId, style, width) |
| 756 | fun useCodeLayouts(layouts: CodeLayoutCache) { | 775 | fun useCodeLayouts(layouts: CodeLayoutCache) { |
| ... | @@ -876,6 +895,7 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -876,6 +895,7 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 876 | setVerticalOffset(verticalOffset) | 895 | setVerticalOffset(verticalOffset) |
| 877 | setHorizontalOffset(horizontalOffset) | 896 | setHorizontalOffset(horizontalOffset) |
| 878 | clampHeaderPathOffsets() | 897 | clampHeaderPathOffsets() |
| 898 | onContentHeightChanged?.invoke(rowOffsets.last()) | ||
| 879 | } | 899 | } |
| 880 | 900 | ||
| 881 | private fun rowHeight(row: DiffRow): Int = when (row.kind) { | 901 | private fun rowHeight(row: DiffRow): Int = when (row.kind) { |
| ... | @@ -1198,14 +1218,7 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -1198,14 +1218,7 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 1198 | } | 1218 | } |
| 1199 | val selected = selectedRowIds.contains(row.id) | 1219 | val selected = selectedRowIds.contains(row.id) |
| 1200 | if (selected) { | 1220 | if (selected) { |
| 1201 | fill( | 1221 | // Keep the diff and syntax colors intact; selection lives in the gutter. |
| 1202 | canvas, | ||
| 1203 | withAlpha(theme.hunkText, 56), | ||
| 1204 | 0f, | ||
| 1205 | top.toFloat(), | ||
| 1206 | width.toFloat(), | ||
| 1207 | bottom.toFloat(), | ||
| 1208 | ) | ||
| 1209 | fill( | 1222 | fill( |
| 1210 | canvas, | 1223 | canvas, |
| 1211 | withAlpha(theme.hunkText, 242), | 1224 | withAlpha(theme.hunkText, 242), |
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift+12| ... | @@ -61,6 +61,18 @@ public class T3ReviewDiffModule: Module { | ... | @@ -61,6 +61,18 @@ public class T3ReviewDiffModule: Module { |
| 61 | view.setInitialRowIndex(initialRowIndex) | 61 | view.setInitialRowIndex(initialRowIndex) |
| 62 | } | 62 | } |
| 63 | 63 | ||
| 64 | Prop("scrollEnabled") { (view: T3ReviewDiffView, enabled: Bool) in | ||
| 65 | view.setScrollEnabled(enabled) | ||
| 66 | } | ||
| 67 | |||
| 68 | Prop("bounces") { (view: T3ReviewDiffView, bounces: Bool) in | ||
| 69 | view.setBounces(bounces) | ||
| 70 | } | ||
| 71 | |||
| 72 | Prop("refreshEnabled") { (view: T3ReviewDiffView, enabled: Bool) in | ||
| 73 | view.setRefreshEnabled(enabled) | ||
| 74 | } | ||
| 75 | |||
| 64 | Prop("refreshing") { (view: T3ReviewDiffView, refreshing: Bool) in | 76 | Prop("refreshing") { (view: T3ReviewDiffView, refreshing: Bool) in |
| 65 | view.setRefreshing(refreshing) | 77 | view.setRefreshing(refreshing) |
| 66 | } | 78 | } |
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+17-7| ... | @@ -365,7 +365,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -365,7 +365,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 365 | scrollView.showsHorizontalScrollIndicator = false | 365 | scrollView.showsHorizontalScrollIndicator = false |
| 366 | scrollView.backgroundColor = contentView.theme.background | 366 | scrollView.backgroundColor = contentView.theme.background |
| 367 | pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged) | 367 | pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged) |
| 368 | scrollView.refreshControl = pullRefreshControl | ||
| 369 | addSubview(scrollView) | 368 | addSubview(scrollView) |
| 370 | 369 | ||
| 371 | contentView.backgroundColor = contentView.theme.background | 370 | contentView.backgroundColor = contentView.theme.background |
| ... | @@ -701,14 +700,14 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -701,14 +700,14 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 701 | applyPendingScrollIfNeeded() | 700 | applyPendingScrollIfNeeded() |
| 702 | emitVisibleFileIfNeeded() | 701 | emitVisibleFileIfNeeded() |
| 703 | 702 | ||
| 704 | let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))" | 703 | let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(contentView.contentHeight))" |
| 705 | if debugKey != lastMetricsDebugKey { | 704 | if debugKey != lastMetricsDebugKey { |
| 706 | lastMetricsDebugKey = debugKey | 705 | lastMetricsDebugKey = debugKey |
| 707 | emitDebug("metrics", [ | 706 | emitDebug("metrics", [ |
| 708 | "rows": rows.count, | 707 | "rows": rows.count, |
| 709 | "boundsWidth": Double(bounds.width), | 708 | "boundsWidth": Double(bounds.width), |
| 710 | "boundsHeight": Double(bounds.height), | 709 | "boundsHeight": Double(bounds.height), |
| 711 | "contentHeight": Double(height), | 710 | "contentHeight": Double(contentView.contentHeight), |
| 712 | "contentWidth": Double(style.contentWidth), | 711 | "contentWidth": Double(style.contentWidth), |
| 713 | "fileHeaderHeight": Double(style.fileHeaderHeight), | 712 | "fileHeaderHeight": Double(style.fileHeaderHeight), |
| 714 | "rowHeight": Double(style.rowHeight), | 713 | "rowHeight": Double(style.rowHeight), |
| ... | @@ -840,6 +839,20 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -840,6 +839,20 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 840 | setVerticalContentOffset(0, animated: animated) | 839 | setVerticalContentOffset(0, animated: animated) |
| 841 | } | 840 | } |
| 842 | 841 | ||
| 842 | func setScrollEnabled(_ enabled: Bool) { | ||
| 843 | scrollView.isScrollEnabled = enabled | ||
| 844 | } | ||
| 845 | |||
| 846 | func setBounces(_ bounces: Bool) { | ||
| 847 | scrollView.bounces = bounces | ||
| 848 | scrollView.alwaysBounceVertical = bounces | ||
| 849 | } | ||
| 850 | |||
| 851 | func setRefreshEnabled(_ enabled: Bool) { | ||
| 852 | if !enabled { pullRefreshControl.endRefreshing() } | ||
| 853 | scrollView.refreshControl = enabled ? pullRefreshControl : nil | ||
| 854 | } | ||
| 855 | |||
| 843 | @objc private func handlePullToRefresh() { | 856 | @objc private func handlePullToRefresh() { |
| 844 | onPullToRefresh([:]) | 857 | onPullToRefresh([:]) |
| 845 | } | 858 | } |
| ... | @@ -2429,10 +2442,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -2429,10 +2442,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2429 | return | 2442 | return |
| 2430 | } | 2443 | } |
| 2431 | 2444 | ||
| 2432 | let selectionColor = theme.hunkText.withAlphaComponent(0.22) | 2445 | // Keep the diff and syntax colors intact; selection lives in the gutter. |
| 2433 | selectionColor.setFill() | ||
| 2434 | context.fill(rect) | ||
| 2435 | |||
| 2436 | theme.hunkText.withAlphaComponent(0.95).setFill() | 2446 | theme.hunkText.withAlphaComponent(0.95).setFill() |
| 2437 | context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height)) | 2447 | context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height)) |
| 2438 | } | 2448 | } |
apps/mobile/src/Stack.tsx+5| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen"; | ||
| 1 | import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen"; | 2 | import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen"; |
| 2 | import { PullRequestDiscussionScreen } from "./features/pull-requests/PullRequestDiscussionScreen"; | 3 | import { PullRequestDiscussionScreen } from "./features/pull-requests/PullRequestDiscussionScreen"; |
| 3 | import { | 4 | import { |
| ... | @@ -722,6 +723,10 @@ const RootStackConfig = createWorkspaceStackNavigator({ | ... | @@ -722,6 +723,10 @@ const RootStackConfig = createWorkspaceStackNavigator({ |
| 722 | }, | 723 | }, |
| 723 | options: GLASS_HEADER_OPTIONS, | 724 | options: GLASS_HEADER_OPTIONS, |
| 724 | }), | 725 | }), |
| 726 | PullRequestGuideStop: createNativeStackScreen({ | ||
| 727 | screen: PullRequestGuideStopScreen, | ||
| 728 | options: GLASS_HEADER_OPTIONS, | ||
| 729 | }), | ||
| 725 | PullRequestFile: createNativeStackScreen({ | 730 | PullRequestFile: createNativeStackScreen({ |
| 726 | screen: PullRequestFileRouteScreen, | 731 | screen: PullRequestFileRouteScreen, |
| 727 | options: GLASS_HEADER_OPTIONS, | 732 | options: GLASS_HEADER_OPTIONS, |
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts+9-1| ... | @@ -124,6 +124,9 @@ export interface NativeReviewDiffViewProps extends ViewProps { | ... | @@ -124,6 +124,9 @@ export interface NativeReviewDiffViewProps extends ViewProps { |
| 124 | readonly rowHeight: number; | 124 | readonly rowHeight: number; |
| 125 | readonly contentWidth: number; | 125 | readonly contentWidth: number; |
| 126 | readonly initialRowIndex?: number; | 126 | readonly initialRowIndex?: number; |
| 127 | /** Disable the native vertical scroller when an excerpt grows inside a page. */ | ||
| 128 | readonly scrollEnabled?: boolean; | ||
| 129 | readonly bounces?: boolean; | ||
| 127 | readonly refreshing?: boolean; | 130 | readonly refreshing?: boolean; |
| 128 | readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>; | 131 | readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>; |
| 129 | readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void; | 132 | readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void; |
| ... | @@ -167,6 +170,7 @@ type NativeReviewDiffRawViewProps = Omit< | ... | @@ -167,6 +170,7 @@ type NativeReviewDiffRawViewProps = Omit< |
| 167 | NativeReviewDiffViewProps, | 170 | NativeReviewDiffViewProps, |
| 168 | "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson" | 171 | "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson" |
| 169 | > & { | 172 | > & { |
| 173 | readonly refreshEnabled: boolean; | ||
| 170 | readonly ref?: Ref<NativeReviewDiffViewRef>; | 174 | readonly ref?: Ref<NativeReviewDiffViewRef>; |
| 171 | }; | 175 | }; |
| 172 | 176 | ||
| ... | @@ -270,7 +274,11 @@ function NativeReviewDiffView(props: NativeReviewDiffViewProps) { | ... | @@ -270,7 +274,11 @@ function NativeReviewDiffView(props: NativeReviewDiffViewProps) { |
| 270 | return null; | 274 | return null; |
| 271 | } | 275 | } |
| 272 | 276 | ||
| 273 | return createElement(RawNativeView, { ...nativeProps, ref: nativeRef }); | 277 | return createElement(RawNativeView, { |
| 278 | ...nativeProps, | ||
| 279 | refreshEnabled: props.onPullToRefresh !== undefined, | ||
| 280 | ref: nativeRef, | ||
| 281 | }); | ||
| 274 | } | 282 | } |
| 275 | 283 | ||
| 276 | export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null { | 284 | export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null { |
apps/mobile/src/features/pull-requests/PullRequestActivityFilters.tsx created+91| ... | @@ -0,0 +1,91 @@ | ||
| 1 | import { Pressable, View } from "react-native"; | ||
| 2 | import { AppText as Text } from "../../components/AppText"; | ||
| 3 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 4 | import { ControlPillMenu } from "../../components/ControlPillMenu"; | ||
| 5 | import type { ActivityFilter } from "./pullRequestActivityFilter"; | ||
| 6 | |||
| 7 | export function PullRequestActivityFilters({ | ||
| 8 | value, | ||
| 9 | onChange, | ||
| 10 | hiddenCount, | ||
| 11 | }: { | ||
| 12 | readonly value: ActivityFilter; | ||
| 13 | readonly onChange: (value: ActivityFilter) => void; | ||
| 14 | readonly hiddenCount: number; | ||
| 15 | }) { | ||
| 16 | const kinds = [ | ||
| 17 | { id: "all", title: "All activity" }, | ||
| 18 | { id: "comments", title: "Comments" }, | ||
| 19 | { id: "commits", title: "Commits" }, | ||
| 20 | ] as const; | ||
| 21 | return ( | ||
| 22 | <View className="mb-4 flex-row items-center justify-between gap-3"> | ||
| 23 | <ControlPillMenu | ||
| 24 | title="Activity" | ||
| 25 | actions={kinds.map((kind) => ({ ...kind, state: value.kind === kind.id ? "on" : "off" }))} | ||
| 26 | onPressAction={({ nativeEvent }) => { | ||
| 27 | const kind = kinds.find((item) => item.id === nativeEvent.event); | ||
| 28 | if (kind) onChange({ ...value, kind: kind.id }); | ||
| 29 | }} | ||
| 30 | > | ||
| 31 | <Pressable | ||
| 32 | accessibilityRole="button" | ||
| 33 | accessibilityLabel={`Activity type, ${kinds.find((kind) => kind.id === value.kind)?.title}`} | ||
| 34 | className="min-h-11 flex-row items-center gap-2 active:opacity-60" | ||
| 35 | > | ||
| 36 | <Text className="text-sm font-t3-semibold"> | ||
| 37 | {kinds.find((kind) => kind.id === value.kind)?.title} | ||
| 38 | </Text> | ||
| 39 | <SymbolView name="chevron.down" size={11} tintColorClassName="accent-icon-muted" /> | ||
| 40 | </Pressable> | ||
| 41 | </ControlPillMenu> | ||
| 42 | <View className="flex-row items-center gap-3"> | ||
| 43 | <ControlPillMenu | ||
| 44 | title="Show in activity" | ||
| 45 | actions={[ | ||
| 46 | { id: "bots", title: "Bot comments", state: value.bots ? "on" : "off" }, | ||
| 47 | { | ||
| 48 | id: "resolved", | ||
| 49 | title: "Resolved or dismissed", | ||
| 50 | state: value.resolved ? "on" : "off", | ||
| 51 | }, | ||
| 52 | ]} | ||
| 53 | onPressAction={({ nativeEvent: { event } }) => { | ||
| 54 | if (event === "bots" || event === "resolved") | ||
| 55 | onChange({ ...value, [event]: !value[event] }); | ||
| 56 | }} | ||
| 57 | > | ||
| 58 | <Pressable | ||
| 59 | accessibilityRole="button" | ||
| 60 | accessibilityLabel={`Filter activity, ${hiddenCount} hidden`} | ||
| 61 | className="min-h-11 flex-row items-center gap-1.5 active:opacity-60" | ||
| 62 | > | ||
| 63 | {hiddenCount > 0 ? ( | ||
| 64 | <Text className="text-xs text-foreground-muted">{hiddenCount} hidden</Text> | ||
| 65 | ) : null} | ||
| 66 | <SymbolView | ||
| 67 | name="line.3.horizontal.decrease" | ||
| 68 | size={18} | ||
| 69 | tintColorClassName="accent-icon-muted" | ||
| 70 | /> | ||
| 71 | </Pressable> | ||
| 72 | </ControlPillMenu> | ||
| 73 | <Pressable | ||
| 74 | accessibilityRole="button" | ||
| 75 | accessibilityLabel={value.newestFirst ? "Newest first" : "Oldest first"} | ||
| 76 | onPress={() => onChange({ ...value, newestFirst: !value.newestFirst })} | ||
| 77 | className="h-11 flex-row items-center gap-1 active:opacity-60" | ||
| 78 | > | ||
| 79 | <SymbolView | ||
| 80 | name={value.newestFirst ? "arrow.down" : "arrow.up"} | ||
| 81 | size={18} | ||
| 82 | tintColorClassName="accent-icon-muted" | ||
| 83 | /> | ||
| 84 | <Text className="text-xs text-foreground-muted"> | ||
| 85 | {value.newestFirst ? "Newest" : "Oldest"} | ||
| 86 | </Text> | ||
| 87 | </Pressable> | ||
| 88 | </View> | ||
| 89 | </View> | ||
| 90 | ); | ||
| 91 | } | ||
apps/mobile/src/features/pull-requests/PullRequestControls.tsx+7-2| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import Animated from "react-native-reanimated"; | ||
| 2 | import { PR_ROW_LAYOUT } from "./pullRequestAnimations"; | ||
| 1 | import { useNavigation } from "@react-navigation/native"; | 3 | import { useNavigation } from "@react-navigation/native"; |
| 2 | import type { PullRequestDetail } from "@t3tools/contracts"; | 4 | import type { PullRequestDetail } from "@t3tools/contracts"; |
| 3 | import type { ReactNode } from "react"; | 5 | import type { ReactNode } from "react"; |
| ... | @@ -21,9 +23,12 @@ import { usePullRequestAgentActions } from "./usePullRequestAgentActions"; | ... | @@ -21,9 +23,12 @@ import { usePullRequestAgentActions } from "./usePullRequestAgentActions"; |
| 21 | 23 | ||
| 22 | export function PullRequestCardGroup(props: { readonly children: ReactNode }) { | 24 | export function PullRequestCardGroup(props: { readonly children: ReactNode }) { |
| 23 | return ( | 25 | return ( |
| 24 | <View className="overflow-hidden rounded-[22px] border-continuous bg-grouped-card"> | 26 | <Animated.View |
| 27 | layout={PR_ROW_LAYOUT} | ||
| 28 | className="overflow-hidden rounded-[22px] border-continuous bg-grouped-card" | ||
| 29 | > | ||
| 25 | {props.children} | 30 | {props.children} |
| 26 | </View> | 31 | </Animated.View> |
| 27 | ); | 32 | ); |
| 28 | } | 33 | } |
| 29 | 34 |
apps/mobile/src/features/pull-requests/PullRequestFiles.tsx+22-9| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | import { LegendList } from "@legendapp/list/react-native"; | 1 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; |
| 2 | import Animated from "react-native-reanimated"; | ||
| 3 | import { PR_ROW_LAYOUT, PR_ROW_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations"; | ||
| 2 | import { useNavigation } from "@react-navigation/native"; | 4 | import { useNavigation } from "@react-navigation/native"; |
| 3 | import { useState } from "react"; | 5 | import { useState } from "react"; |
| 4 | import { ActivityIndicator, Pressable, View } from "react-native"; | 6 | import { ActivityIndicator, Pressable, View } from "react-native"; |
| ... | @@ -192,7 +194,7 @@ export function usePullRequestFilesContent(props: { | ... | @@ -192,7 +194,7 @@ export function usePullRequestFilesContent(props: { |
| 192 | </View> | 194 | </View> |
| 193 | ) : parsed.kind === "files" ? ( | 195 | ) : parsed.kind === "files" ? ( |
| 194 | <> | 196 | <> |
| 195 | <View className="flex-row items-center gap-2 py-3"> | 197 | <View className="min-h-17 flex-row items-center gap-2 py-3"> |
| 196 | <View className="min-w-0 flex-1 gap-1"> | 198 | <View className="min-w-0 flex-1 gap-1"> |
| 197 | <Text className="text-base font-t3-semibold"> | 199 | <Text className="text-base font-t3-semibold"> |
| 198 | {parsed.fileCount} {filter.hiddenCount ? "visible" : "changed"}{" "} | 200 | {parsed.fileCount} {filter.hiddenCount ? "visible" : "changed"}{" "} |
| ... | @@ -201,11 +203,11 @@ export function usePullRequestFilesContent(props: { | ... | @@ -201,11 +203,11 @@ export function usePullRequestFilesContent(props: { |
| 201 | ? ` · ${parsed.files.filter((file) => viewed.isViewed(file.path)).length} viewed` | 203 | ? ` · ${parsed.files.filter((file) => viewed.isViewed(file.path)).length} viewed` |
| 202 | : ""} | 204 | : ""} |
| 203 | </Text> | 205 | </Text> |
| 204 | {filter.hideTestFiles ? ( | 206 | <Text className="text-2xs text-foreground-muted"> |
| 205 | <Text className="text-2xs text-foreground-muted"> | 207 | {filter.hideTestFiles |
| 206 | {filter.hiddenCount} test {filter.hiddenCount === 1 ? "file" : "files"} hidden | 208 | ? `${filter.hiddenCount} test ${filter.hiddenCount === 1 ? "file" : "files"} hidden` |
| 207 | </Text> | 209 | : "Test files included"} |
| 208 | ) : null} | 210 | </Text> |
| 209 | </View> | 211 | </View> |
| 210 | <PullRequestDiffStats | 212 | <PullRequestDiffStats |
| 211 | additions={parsed.additions} | 213 | additions={parsed.additions} |
| ... | @@ -285,11 +287,22 @@ export function usePullRequestFilesContent(props: { | ... | @@ -285,11 +287,22 @@ export function usePullRequestFilesContent(props: { |
| 285 | 287 | ||
| 286 | export function PullRequestFiles(props: Parameters<typeof usePullRequestFilesContent>[0]) { | 288 | export function PullRequestFiles(props: Parameters<typeof usePullRequestFilesContent>[0]) { |
| 287 | const content = usePullRequestFilesContent(props); | 289 | const content = usePullRequestFilesContent(props); |
| 290 | const [animationsReady, setAnimationsReady] = useState(false); | ||
| 288 | return ( | 291 | return ( |
| 289 | <LegendList | 292 | <AnimatedLegendList |
| 290 | data={content.rows} | 293 | data={content.rows} |
| 291 | keyExtractor={(row) => row.key} | 294 | keyExtractor={(row) => row.key} |
| 292 | renderItem={({ item }) => item.content} | 295 | itemLayoutAnimation={PR_ROW_LAYOUT} |
| 296 | onLoad={() => setAnimationsReady(true)} | ||
| 297 | renderItem={({ item }) => ( | ||
| 298 | <Animated.View | ||
| 299 | key={item.key} | ||
| 300 | entering={animationsReady ? PR_ROW_ENTER : undefined} | ||
| 301 | exiting={animationsReady ? PR_ROW_EXIT : undefined} | ||
| 302 | > | ||
| 303 | {item.content} | ||
| 304 | </Animated.View> | ||
| 305 | )} | ||
| 293 | recycleItems={false} | 306 | recycleItems={false} |
| 294 | estimatedItemSize={64} | 307 | estimatedItemSize={64} |
| 295 | ListHeaderComponent={content.header} | 308 | ListHeaderComponent={content.header} |
apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx+315-246| ... | @@ -1,5 +1,22 @@ | ... | @@ -1,5 +1,22 @@ |
| 1 | import { PullRequestEntryRow } from "./PullRequestEntryRow"; | 1 | import { pullRequestTabOffset } from "./pullRequestTabScroll"; |
| 2 | import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; | 2 | import { guideFocusedRows, type GuideDiffFocus } from "./guide/guideDiffFocus"; |
| 3 | import { PullRequestGuideContent } from "./guide/PullRequestGuideScreen"; | ||
| 4 | import { useReviewGuidesEnabled } from "./guide/useReviewGuide"; | ||
| 5 | import { | ||
| 6 | filterPullRequestActivity, | ||
| 7 | DEFAULT_ACTIVITY_FILTER, | ||
| 8 | type ActivityFilter, | ||
| 9 | } from "./pullRequestActivityFilter"; | ||
| 10 | import { PullRequestActivityFilters } from "./PullRequestActivityFilters"; | ||
| 11 | import type { LegendListRef } from "@legendapp/list/react-native"; | ||
| 12 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; | ||
| 13 | import Animated from "react-native-reanimated"; | ||
| 14 | import { | ||
| 15 | PR_ROW_LAYOUT, | ||
| 16 | PR_ROW_ENTER, | ||
| 17 | PR_ROW_EXIT, | ||
| 18 | PR_POSITION_LAYOUT, | ||
| 19 | } from "./pullRequestAnimations"; | ||
| 3 | import { openOnProviderLabel } from "./pullRequestActions"; | 20 | import { openOnProviderLabel } from "./pullRequestActions"; |
| 4 | import * as Clipboard from "expo-clipboard"; | 21 | import * as Clipboard from "expo-clipboard"; |
| 5 | import { ControlPillMenu } from "../../components/ControlPillMenu"; | 22 | import { ControlPillMenu } from "../../components/ControlPillMenu"; |
| ... | @@ -23,7 +40,7 @@ import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; | ... | @@ -23,7 +40,7 @@ import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; |
| 23 | import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/contracts"; | 40 | import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/contracts"; |
| 24 | import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading"; | 41 | import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading"; |
| 25 | import { pullRequestStatus } from "./pullRequestStatus"; | 42 | import { pullRequestStatus } from "./pullRequestStatus"; |
| 26 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react"; | 43 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 27 | import { | 44 | import { |
| 28 | ActivityIndicator, | 45 | ActivityIndicator, |
| 29 | Linking, | 46 | Linking, |
| ... | @@ -104,76 +121,39 @@ function PullRequestTabScrollView(props: ScrollViewProps) { | ... | @@ -104,76 +121,39 @@ function PullRequestTabScrollView(props: ScrollViewProps) { |
| 104 | ); | 121 | ); |
| 105 | } | 122 | } |
| 106 | 123 | ||
| 107 | function usePullRequestActivityRows(params: PullRequestParams, commitsOnly: boolean) { | 124 | function usePullRequestActivityRows(params: PullRequestParams, filter: ActivityFilter) { |
| 108 | const navigation = useNavigation(); | 125 | const navigation = useNavigation(); |
| 109 | const result = useEnvironmentQuery(composerPullRequests.activity(requestTarget(params))); | 126 | const result = useEnvironmentQuery(composerPullRequests.activity(requestTarget(params))); |
| 127 | const filtered = useMemo( | ||
| 128 | () => (result.data ? filterPullRequestActivity(result.data, filter) : null), | ||
| 129 | [result.data, filter], | ||
| 130 | ); | ||
| 110 | const rows = useMemo(() => { | 131 | const rows = useMemo(() => { |
| 111 | const rows: { key: string; content: ReactElement }[] = []; | 132 | if (!filtered) return []; |
| 112 | if (commitsOnly) { | 133 | const rows = pullRequestTimelineRows(filtered.activity, params, navigation); |
| 113 | for (const commit of result.data?.commits ?? []) | 134 | if (filter.newestFirst) { |
| 114 | rows.push({ | 135 | const notice = rows.at(-1)?.key === "truncated" ? rows.pop() : undefined; |
| 115 | key: `commit:${commit.oid}`, | 136 | rows.reverse(); |
| 116 | content: ( | 137 | if (notice) rows.push(notice); |
| 117 | <Pressable | ||
| 118 | key={commit.oid} | ||
| 119 | accessibilityRole="button" | ||
| 120 | accessibilityLabel={`View changes in commit ${commit.oid.slice(0, 7)}, ${commit.messageHeadline}`} | ||
| 121 | onPress={() => | ||
| 122 | navigation.dispatch( | ||
| 123 | StackActions.push("PullRequestFile", { | ||
| 124 | ...params, | ||
| 125 | commit: commit.oid, | ||
| 126 | path: "", | ||
| 127 | }), | ||
| 128 | ) | ||
| 129 | } | ||
| 130 | className={`border-b border-border-subtle pb-3 active:opacity-70 ${rows.length ? "pt-3" : ""}`} | ||
| 131 | > | ||
| 132 | <PullRequestEntryRow | ||
| 133 | leading={ | ||
| 134 | <View className="h-8 items-center justify-center"> | ||
| 135 | <PullRequestAvatar actor={commit.authors?.[0] ?? null} size={24} /> | ||
| 136 | </View> | ||
| 137 | } | ||
| 138 | > | ||
| 139 | <View className="gap-1"> | ||
| 140 | <View className="min-h-8 flex-row items-center gap-2"> | ||
| 141 | <Text className="min-w-0 flex-1 text-sm" numberOfLines={1}> | ||
| 142 | {commit.messageHeadline} | ||
| 143 | </Text> | ||
| 144 | <Text className="font-mono text-2xs text-foreground-muted"> | ||
| 145 | {commit.oid.slice(0, 7)} | ||
| 146 | </Text> | ||
| 147 | <SymbolView | ||
| 148 | name="chevron.right" | ||
| 149 | size={11} | ||
| 150 | tintColorClassName="accent-icon-muted" | ||
| 151 | /> | ||
| 152 | </View> | ||
| 153 | <Text className="text-2xs text-foreground-muted"> | ||
| 154 | {commit.authors?.map((author) => author.login).join(", ") || "Commit"} ·{" "} | ||
| 155 | {relativeTime(commit.committedDate)} | ||
| 156 | </Text> | ||
| 157 | </View> | ||
| 158 | </PullRequestEntryRow> | ||
| 159 | </Pressable> | ||
| 160 | ), | ||
| 161 | }); | ||
| 162 | } else if (result.data) { | ||
| 163 | rows.push(...pullRequestTimelineRows(result.data, params, navigation)); | ||
| 164 | } | 138 | } |
| 165 | if (!rows.length && result.data) | 139 | if (!rows.length) |
| 166 | rows.push({ | 140 | rows.push({ |
| 167 | key: "empty", | 141 | key: "empty", |
| 142 | date: "", | ||
| 168 | content: ( | 143 | content: ( |
| 169 | <Text className="py-6 text-sm text-foreground-muted"> | 144 | <Text className="py-6 text-sm text-foreground-muted"> |
| 170 | {commitsOnly ? "No commits reported." : "No comments yet."} | 145 | No activity matches these filters. |
| 171 | </Text> | 146 | </Text> |
| 172 | ), | 147 | ), |
| 173 | }); | 148 | }); |
| 174 | return rows; | 149 | return rows; |
| 175 | }, [commitsOnly, navigation, params, result.data]); | 150 | }, [filtered, filter.newestFirst, params, navigation]); |
| 176 | return { rows, pending: result.isPending, error: result.error }; | 151 | return { |
| 152 | rows, | ||
| 153 | hiddenCount: filtered?.hiddenCount ?? 0, | ||
| 154 | pending: result.isPending, | ||
| 155 | error: result.error, | ||
| 156 | }; | ||
| 177 | } | 157 | } |
| 178 | 158 | ||
| 179 | function PullRequestIdentity(props: { | 159 | function PullRequestIdentity(props: { |
| ... | @@ -257,26 +237,33 @@ function PullRequestOverview(props: { | ... | @@ -257,26 +237,33 @@ function PullRequestOverview(props: { |
| 257 | <PullRequestCardGroup> | 237 | <PullRequestCardGroup> |
| 258 | <PullRequestChecksRow pr={pr} params={props.params} /> | 238 | <PullRequestChecksRow pr={pr} params={props.params} /> |
| 259 | <PullRequestReviewersRow pr={pr} params={props.params} /> | 239 | <PullRequestReviewersRow pr={pr} params={props.params} /> |
| 260 | <PullRequestBranchRow pr={pr} params={props.params} /> | 240 | <Animated.View layout={PR_ROW_LAYOUT}> |
| 261 | <PullRequestMergeControls pr={pr} params={props.params} stack={props.stack} /> | 241 | <PullRequestBranchRow pr={pr} params={props.params} /> |
| 242 | </Animated.View> | ||
| 243 | <Animated.View layout={PR_ROW_LAYOUT}> | ||
| 244 | <PullRequestMergeControls pr={pr} params={props.params} stack={props.stack} /> | ||
| 245 | </Animated.View> | ||
| 262 | </PullRequestCardGroup> | 246 | </PullRequestCardGroup> |
| 263 | {pr.labels.length ? ( | 247 | {pr.labels.length ? ( |
| 264 | <View className="flex-row flex-wrap gap-2"> | 248 | <Animated.View layout={PR_ROW_LAYOUT} className="flex-row flex-wrap gap-2"> |
| 265 | {pr.labels.map((label) => ( | 249 | {pr.labels.map((label) => ( |
| 266 | <View key={label.name} className="rounded-full bg-grouped-card px-3 py-1"> | 250 | <View key={label.name} className="rounded-full bg-grouped-card px-3 py-1"> |
| 267 | <Text className="text-2xs text-foreground-muted">{label.name}</Text> | 251 | <Text className="text-2xs text-foreground-muted">{label.name}</Text> |
| 268 | </View> | 252 | </View> |
| 269 | ))} | 253 | ))} |
| 270 | </View> | 254 | </Animated.View> |
| 271 | ) : null} | 255 | ) : null} |
| 272 | <View onLayout={(event) => props.onDescriptionLayout(event.nativeEvent.layout.y)}> | 256 | <Animated.View |
| 257 | layout={PR_POSITION_LAYOUT} | ||
| 258 | onLayout={(event) => props.onDescriptionLayout(event.nativeEvent.layout.y)} | ||
| 259 | > | ||
| 273 | <PullRequestDescription | 260 | <PullRequestDescription |
| 274 | markdown={pr.body} | 261 | markdown={pr.body} |
| 275 | onCollapse={props.onDescriptionCollapse} | 262 | onCollapse={props.onDescriptionCollapse} |
| 276 | expanded={props.descriptionExpanded} | 263 | expanded={props.descriptionExpanded} |
| 277 | onExpandedChange={props.onDescriptionExpandedChange} | 264 | onExpandedChange={props.onDescriptionExpandedChange} |
| 278 | /> | 265 | /> |
| 279 | </View> | 266 | </Animated.View> |
| 280 | </View> | 267 | </View> |
| 281 | ); | 268 | ); |
| 282 | } | 269 | } |
| ... | @@ -285,43 +272,45 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP | ... | @@ -285,43 +272,45 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 285 | const navigation = useNavigation(); | 272 | const navigation = useNavigation(); |
| 286 | const insets = useSafeAreaInsets(); | 273 | const insets = useSafeAreaInsets(); |
| 287 | const params = route.params; | 274 | const params = route.params; |
| 288 | const [section, setSection] = useState<"overview" | "activity" | "commits">("overview"); | 275 | const [section, setSection] = useState<"overview" | "activity" | "guide">("overview"); |
| 289 | // LegendList commits replacement rows after the header. Keep selection with the visible content. | 276 | // Keep each tab's native list and measured rows alive when it is hidden. |
| 290 | const [displayedSection, setDisplayedSection] = useState(section); | ||
| 291 | const requestedSection = useRef(section); | 277 | const requestedSection = useRef(section); |
| 278 | const [listAnimationsReady, setListAnimationsReady] = useState(false); | ||
| 292 | const [descriptionExpanded, setDescriptionExpanded] = useState(false); | 279 | const [descriptionExpanded, setDescriptionExpanded] = useState(false); |
| 293 | const filesContent = usePullRequestFilesContent({ params }); | 280 | const filesContent = usePullRequestFilesContent({ params }); |
| 294 | const activityContent = usePullRequestActivityRows(params, false); | 281 | const guidesEnabled = useReviewGuidesEnabled(params); |
| 295 | const commitsContent = usePullRequestActivityRows(params, true); | 282 | const [activityFilter, setActivityFilter] = useState<ActivityFilter>(DEFAULT_ACTIVITY_FILTER); |
| 283 | const activityContent = usePullRequestActivityRows(params, activityFilter); | ||
| 296 | const scrollOffsets = useRef<Record<string, number>>({}); | 284 | const scrollOffsets = useRef<Record<string, number>>({}); |
| 297 | const currentScrollOffset = useRef(-insets.top); | 285 | const currentScrollOffset = useRef(-insets.top); |
| 298 | const bodyY = useRef(0); | 286 | const bodyY = useRef(0); |
| 299 | const pendingScrollOffset = useRef<number | null>(null); | 287 | const [headerHeight, setHeaderHeight] = useState(0); |
| 300 | const selectSection = (next: "overview" | "activity" | "commits") => { | 288 | const [viewportHeight, setViewportHeight] = useState(0); |
| 289 | const scrollViews = useRef<Partial<Record<typeof section, LegendListRef | null>>>({}); | ||
| 290 | const selectSection = (next: typeof section) => { | ||
| 301 | if (next === section) return; | 291 | if (next === section) return; |
| 302 | requestedSection.current = next; | ||
| 303 | scrollOffsets.current[section] = currentScrollOffset.current; | 292 | scrollOffsets.current[section] = currentScrollOffset.current; |
| 304 | if (scrollOffsets.current[next] === undefined) { | 293 | const headerEnd = Math.max(-insets.top, bodyY.current - insets.top); |
| 305 | scrollOffsets.current[next] = Math.min( | 294 | // The shared header stays in place. Once collapsed, retain each tab's own reading position. |
| 306 | currentScrollOffset.current, | 295 | const offset = pullRequestTabOffset( |
| 307 | bodyY.current - insets.top, | 296 | currentScrollOffset.current, |
| 308 | ); | 297 | scrollOffsets.current[next], |
| 309 | } | 298 | headerEnd, |
| 310 | pendingScrollOffset.current = scrollOffsets.current[next] ?? -insets.top; | 299 | ); |
| 300 | requestedSection.current = next; | ||
| 301 | scrollOffsets.current[next] = offset; | ||
| 302 | currentScrollOffset.current = offset; | ||
| 303 | scrollViews.current[next]?.scrollToOffset({ offset, animated: false }); | ||
| 311 | setSection(next); | 304 | setSection(next); |
| 312 | }; | 305 | }; |
| 313 | const tab = section; | 306 | useEffect(() => { |
| 314 | const tabContent = section === "commits" ? commitsContent : activityContent; | 307 | if (!guidesEnabled && section === "guide") { |
| 315 | const scrollView = useRef<LegendListRef>(null); | 308 | requestedSection.current = "overview"; |
| 316 | const commitSectionLayout = () => { | 309 | currentScrollOffset.current = scrollOffsets.current.overview ?? -insets.top; |
| 317 | if (requestedSection.current !== section) return; | 310 | setSection("overview"); |
| 318 | setDisplayedSection(section); | ||
| 319 | const offset = pendingScrollOffset.current; | ||
| 320 | if (offset !== null) { | ||
| 321 | pendingScrollOffset.current = null; | ||
| 322 | scrollView.current?.scrollToOffset({ offset, animated: false }); | ||
| 323 | } | 311 | } |
| 324 | }; | 312 | }, [guidesEnabled, section, insets.top]); |
| 313 | const tabContent = activityContent; | ||
| 325 | const filesY = useRef(0); | 314 | const filesY = useRef(0); |
| 326 | const descriptionY = useRef(0); | 315 | const descriptionY = useRef(0); |
| 327 | const pendingDescriptionJump = useRef(false); | 316 | const pendingDescriptionJump = useRef(false); |
| ... | @@ -329,7 +318,6 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP | ... | @@ -329,7 +318,6 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 329 | const detailAtom = composerPullRequests.detail(requestTarget(params)); | 318 | const detailAtom = composerPullRequests.detail(requestTarget(params)); |
| 330 | const result = useEnvironmentQuery(detailAtom); | 319 | const result = useEnvironmentQuery(detailAtom); |
| 331 | const pullRefresh = usePullToRefresh(detailAtom); | 320 | const pullRefresh = usePullToRefresh(detailAtom); |
| 332 | const activity = useEnvironmentQuery(composerPullRequests.activity(requestTarget(params))); | ||
| 333 | const { refresh: refreshDetail } = result; | 321 | const { refresh: refreshDetail } = result; |
| 334 | useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail])); | 322 | useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail])); |
| 335 | const pr = result.data; | 323 | const pr = result.data; |
| ... | @@ -338,24 +326,27 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP | ... | @@ -338,24 +326,27 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 338 | const permissions = usePullRequestPermissions(params, pr); | 326 | const permissions = usePullRequestPermissions(params, pr); |
| 339 | const stack = usePullRequestStack(params, pr); | 327 | const stack = usePullRequestStack(params, pr); |
| 340 | const refreshActivity = useAtomRefresh(composerPullRequests.activity(requestTarget(params))); | 328 | const refreshActivity = useAtomRefresh(composerPullRequests.activity(requestTarget(params))); |
| 329 | const refreshGuide = useAtomRefresh(composerPullRequests.reviewGuide(requestTarget(params))); | ||
| 341 | const refresh = () => { | 330 | const refresh = () => { |
| 342 | pullRefresh.onRefresh(); | 331 | pullRefresh.onRefresh(); |
| 332 | if (guidesEnabled) refreshGuide(); | ||
| 343 | stack.refresh(); | 333 | stack.refresh(); |
| 344 | refreshActivity(); | 334 | refreshActivity(); |
| 345 | filesContent.refresh(); | 335 | filesContent.refresh(); |
| 346 | }; | 336 | }; |
| 347 | const jumpToFiles = useCallback(() => { | 337 | const jumpToFiles = () => { |
| 348 | scrollView.current?.scrollToOffset({ | 338 | scrollViews.current.overview?.scrollToOffset({ |
| 349 | offset: bodyY.current + filesY.current - insets.top, | 339 | offset: bodyY.current + filesY.current - insets.top, |
| 350 | animated: true, | 340 | animated: true, |
| 351 | }); | 341 | }); |
| 352 | pendingFilesJump.current = false; | 342 | pendingFilesJump.current = false; |
| 353 | }, [insets.top]); | 343 | }; |
| 354 | const openFiles = () => { | 344 | const openFiles = () => { |
| 355 | if (section === "overview") jumpToFiles(); | 345 | if (section === "overview") jumpToFiles(); |
| 356 | else { | 346 | else { |
| 357 | pendingFilesJump.current = true; | 347 | pendingFilesJump.current = true; |
| 358 | selectSection("overview"); | 348 | selectSection("overview"); |
| 349 | if (filesY.current > 0) requestAnimationFrame(jumpToFiles); | ||
| 359 | } | 350 | } |
| 360 | }; | 351 | }; |
| 361 | return ( | 352 | return ( |
| ... | @@ -443,155 +434,207 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP | ... | @@ -443,155 +434,207 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 443 | ) : null} | 434 | ) : null} |
| 444 | </NativeHeaderToolbar.Menu> | 435 | </NativeHeaderToolbar.Menu> |
| 445 | </NativeHeaderToolbar> | 436 | </NativeHeaderToolbar> |
| 446 | <View style={{ flex: 1 }}> | 437 | <View |
| 447 | <LegendList | 438 | style={{ flex: 1 }} |
| 448 | ref={scrollView} | 439 | onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)} |
| 449 | initialScrollOffset={-insets.top} | 440 | > |
| 450 | onScroll={(event) => { | 441 | {(["overview", "guide", "activity"] as const) |
| 451 | currentScrollOffset.current = event.nativeEvent.contentOffset.y; | 442 | .filter((tab) => tab !== "guide" || guidesEnabled) |
| 452 | }} | 443 | .map((tab) => ( |
| 453 | className="flex-1" | 444 | <View |
| 454 | renderScrollComponent={Platform.OS === "ios" ? PullRequestTabScrollView : undefined} | 445 | key={tab} |
| 455 | contentInsetAdjustmentBehavior="never" | 446 | style={[StyleSheet.absoluteFill, { opacity: section === tab ? 1 : 0 }]} |
| 456 | contentInset={ | 447 | pointerEvents={section === tab ? "auto" : "none"} |
| 457 | Platform.OS === "ios" ? { top: insets.top, bottom: insets.bottom } : undefined | 448 | accessibilityElementsHidden={section !== tab} |
| 458 | } | 449 | importantForAccessibility={section === tab ? "auto" : "no-hide-descendants"} |
| 459 | contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} | 450 | > |
| 460 | refreshControl={ | 451 | <AnimatedLegendList |
| 461 | <RefreshControl refreshing={pullRefresh.refreshing} onRefresh={refresh} /> | 452 | ref={(list) => { |
| 462 | } | 453 | scrollViews.current[tab] = list; |
| 463 | alwaysBounceVertical | 454 | }} |
| 464 | scrollsToTop | 455 | initialScrollOffset={-insets.top} |
| 465 | onContentSizeChange={() => { | 456 | onScroll={(event) => { |
| 466 | if (tab !== "overview" || !pendingDescriptionJump.current) return; | 457 | const offset = event.nativeEvent.contentOffset.y; |
| 467 | pendingDescriptionJump.current = false; | 458 | scrollOffsets.current[tab] = offset; |
| 468 | // Collapse changes the scroll range; restore the heading after that layout commits. | 459 | if (requestedSection.current === tab) currentScrollOffset.current = offset; |
| 469 | scrollView.current?.scrollToOffset({ | 460 | }} |
| 470 | offset: bodyY.current + descriptionY.current - insets.top, | 461 | className="flex-1" |
| 471 | animated: false, | 462 | renderScrollComponent={ |
| 472 | }); | 463 | Platform.OS === "ios" ? PullRequestTabScrollView : undefined |
| 473 | }} | 464 | } |
| 474 | contentContainerStyle={{ | 465 | contentInsetAdjustmentBehavior="never" |
| 475 | paddingHorizontal: 16, | 466 | contentInset={ |
| 476 | paddingTop: 8, | 467 | Platform.OS === "ios" ? { top: insets.top, bottom: insets.bottom } : undefined |
| 477 | paddingBottom: 24, | 468 | } |
| 478 | }} | 469 | contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} |
| 479 | data={[ | 470 | refreshControl={ |
| 480 | { | 471 | <RefreshControl |
| 481 | key: `${tab}:body`, | 472 | refreshing={section === tab && pullRefresh.refreshing} |
| 482 | content: ( | 473 | onRefresh={refresh} |
| 483 | <View | 474 | /> |
| 484 | onLayout={ | 475 | } |
| 485 | tab === "overview" || tabContent.rows.length === 0 | 476 | alwaysBounceVertical |
| 486 | ? commitSectionLayout | 477 | scrollsToTop={section === tab} |
| 487 | : undefined | 478 | onContentSizeChange={() => { |
| 488 | } | 479 | if ( |
| 489 | > | 480 | tab !== "overview" || |
| 490 | {tab === "overview" && identity ? ( | 481 | section !== "overview" || |
| 491 | <View> | 482 | !pendingDescriptionJump.current |
| 492 | {pr ? ( | 483 | ) |
| 493 | <PullRequestOverview | 484 | return; |
| 494 | pr={pr} | 485 | pendingDescriptionJump.current = false; |
| 495 | params={params} | 486 | // Collapse changes the scroll range; restore the heading after that layout commits. |
| 496 | stack={stack} | 487 | scrollViews.current.overview?.scrollToOffset({ |
| 497 | descriptionExpanded={descriptionExpanded} | 488 | offset: bodyY.current + descriptionY.current - insets.top, |
| 498 | onDescriptionExpandedChange={setDescriptionExpanded} | 489 | animated: false, |
| 499 | onDescriptionLayout={(y) => { | 490 | }); |
| 500 | descriptionY.current = y; | 491 | }} |
| 501 | }} | 492 | contentContainerStyle={{ |
| 502 | onDescriptionCollapse={() => { | 493 | paddingHorizontal: 16, |
| 503 | pendingDescriptionJump.current = true; | 494 | paddingTop: 8, |
| 504 | }} | 495 | paddingBottom: 24, |
| 505 | /> | 496 | // Short tabs must still be tall enough to preserve the collapsed header. |
| 506 | ) : summary ? ( | 497 | minHeight: Math.max( |
| 507 | <PullRequestOverviewLoading | 498 | 0, |
| 508 | summary={summary} | 499 | viewportHeight + headerHeight - insets.top - insets.bottom, |
| 509 | params={params} | 500 | ), |
| 510 | stack={stack} | 501 | }} |
| 511 | /> | 502 | data={[ |
| 512 | ) : null} | 503 | { |
| 504 | key: `${tab}:body`, | ||
| 505 | content: ( | ||
| 506 | <View> | ||
| 507 | {tab === "overview" && identity ? ( | ||
| 508 | <View> | ||
| 509 | {pr ? ( | ||
| 510 | <PullRequestOverview | ||
| 511 | pr={pr} | ||
| 512 | params={params} | ||
| 513 | stack={stack} | ||
| 514 | descriptionExpanded={descriptionExpanded} | ||
| 515 | onDescriptionExpandedChange={setDescriptionExpanded} | ||
| 516 | onDescriptionLayout={(y) => { | ||
| 517 | descriptionY.current = y; | ||
| 518 | }} | ||
| 519 | onDescriptionCollapse={() => { | ||
| 520 | pendingDescriptionJump.current = true; | ||
| 521 | }} | ||
| 522 | /> | ||
| 523 | ) : summary ? ( | ||
| 524 | <PullRequestOverviewLoading | ||
| 525 | summary={summary} | ||
| 526 | params={params} | ||
| 527 | stack={stack} | ||
| 528 | /> | ||
| 529 | ) : null} | ||
| 530 | <View | ||
| 531 | className="mt-8 border-t border-border-subtle pt-3" | ||
| 532 | onLayout={(event) => { | ||
| 533 | filesY.current = event.nativeEvent.layout.y; | ||
| 534 | if (pendingFilesJump.current) { | ||
| 535 | requestAnimationFrame(() => { | ||
| 536 | if (requestedSection.current === "overview") jumpToFiles(); | ||
| 537 | }); | ||
| 538 | } | ||
| 539 | }} | ||
| 540 | > | ||
| 541 | {filesContent.header} | ||
| 542 | </View> | ||
| 543 | </View> | ||
| 544 | ) : null} | ||
| 545 | {tab === "guide" ? <PullRequestGuideContent params={params} /> : null} | ||
| 546 | {tab === "activity" ? ( | ||
| 547 | <PullRequestActivityFilters | ||
| 548 | value={activityFilter} | ||
| 549 | onChange={setActivityFilter} | ||
| 550 | hiddenCount={tabContent.hiddenCount} | ||
| 551 | /> | ||
| 552 | ) : null} | ||
| 553 | {tab === "activity" ? ( | ||
| 554 | <PullRequestReadStatus | ||
| 555 | pending={tabContent.pending} | ||
| 556 | error={tabContent.error} | ||
| 557 | /> | ||
| 558 | ) : null} | ||
| 559 | </View> | ||
| 560 | ), | ||
| 561 | }, | ||
| 562 | ...(tab === "overview" | ||
| 563 | ? identity | ||
| 564 | ? filesContent.rows | ||
| 565 | : [] | ||
| 566 | : tab === "activity" | ||
| 567 | ? tabContent.rows | ||
| 568 | : [] | ||
| 569 | ).map((row) => ({ | ||
| 570 | ...row, | ||
| 571 | key: `${tab}:${row.key}`, | ||
| 572 | content: row.content, | ||
| 573 | })), | ||
| 574 | ]} | ||
| 575 | keyExtractor={(row) => row.key} | ||
| 576 | getItemType={(row) => | ||
| 577 | row.key.endsWith(":body") ? row.key : row.key.split(":")[0] | ||
| 578 | } | ||
| 579 | // The overview body contains the clipped description and owns collapse scroll | ||
| 580 | // correction. Animating that cell fights both; file rows still fade individually. | ||
| 581 | itemLayoutAnimation={tab === "overview" ? undefined : PR_ROW_LAYOUT} | ||
| 582 | onLoad={() => setListAnimationsReady(true)} | ||
| 583 | renderItem={({ item }) => ( | ||
| 584 | <Animated.View | ||
| 585 | key={item.key} | ||
| 586 | entering={ | ||
| 587 | listAnimationsReady && !item.key.endsWith(":body") | ||
| 588 | ? PR_ROW_ENTER | ||
| 589 | : undefined | ||
| 590 | } | ||
| 591 | exiting={ | ||
| 592 | listAnimationsReady && !item.key.endsWith(":body") ? PR_ROW_EXIT : undefined | ||
| 593 | } | ||
| 594 | > | ||
| 595 | {item.content} | ||
| 596 | </Animated.View> | ||
| 597 | )} | ||
| 598 | recycleItems={false} | ||
| 599 | estimatedItemSize={tab === "overview" ? 64 : 220} | ||
| 600 | maintainVisibleContentPosition={false} | ||
| 601 | ListHeaderComponent={ | ||
| 602 | <> | ||
| 603 | <PullRequestReadStatus | ||
| 604 | pending={result.isPending && !identity} | ||
| 605 | error={result.error} | ||
| 606 | /> | ||
| 607 | {identity ? ( | ||
| 513 | <View | 608 | <View |
| 514 | className="mt-8 border-t border-border-subtle pt-3" | 609 | className="gap-5 pb-5" |
| 515 | onLayout={(event) => { | 610 | onLayout={(event) => { |
| 516 | filesY.current = event.nativeEvent.layout.y; | 611 | bodyY.current = event.nativeEvent.layout.height; |
| 517 | if (pendingFilesJump.current) { | 612 | setHeaderHeight(event.nativeEvent.layout.height); |
| 518 | // The explicit file jump supersedes the tab's restored offset. | ||
| 519 | pendingScrollOffset.current = null; | ||
| 520 | requestAnimationFrame(() => { | ||
| 521 | if (requestedSection.current === "overview") jumpToFiles(); | ||
| 522 | }); | ||
| 523 | } | ||
| 524 | }} | 613 | }} |
| 525 | > | 614 | > |
| 526 | {filesContent.header} | 615 | <PullRequestIdentity pr={identity} params={params} /> |
| 616 | <SegmentedControl | ||
| 617 | className="bg-grouped-card" | ||
| 618 | options={[ | ||
| 619 | { value: "overview", label: "Overview" }, | ||
| 620 | ...(guidesEnabled | ||
| 621 | ? [{ value: "guide" as const, label: "Guide" }] | ||
| 622 | : []), | ||
| 623 | { value: "activity", label: "Activity" }, | ||
| 624 | ]} | ||
| 625 | selected={section} | ||
| 626 | onSelect={selectSection} | ||
| 627 | role="tab" | ||
| 628 | size="compact" | ||
| 629 | /> | ||
| 527 | </View> | 630 | </View> |
| 528 | </View> | 631 | ) : null} |
| 529 | ) : null} | 632 | </> |
| 530 | {tab !== "overview" ? ( | 633 | } |
| 531 | <PullRequestReadStatus | 634 | ListFooterComponent={tab === "overview" ? filesContent.footer : null} |
| 532 | pending={tabContent.pending} | ||
| 533 | error={tabContent.error} | ||
| 534 | /> | ||
| 535 | ) : null} | ||
| 536 | </View> | ||
| 537 | ), | ||
| 538 | }, | ||
| 539 | ...(tab === "overview" ? (identity ? filesContent.rows : []) : tabContent.rows).map( | ||
| 540 | (row) => ({ | ||
| 541 | ...row, | ||
| 542 | key: `${tab}:${row.key}`, | ||
| 543 | content: | ||
| 544 | tab !== "overview" ? ( | ||
| 545 | <View onLayout={commitSectionLayout}>{row.content}</View> | ||
| 546 | ) : ( | ||
| 547 | row.content | ||
| 548 | ), | ||
| 549 | }), | ||
| 550 | ), | ||
| 551 | ]} | ||
| 552 | keyExtractor={(row) => row.key} | ||
| 553 | getItemType={(row) => (row.key.endsWith(":body") ? row.key : row.key.split(":")[0])} | ||
| 554 | renderItem={({ item }) => item.content} | ||
| 555 | recycleItems={false} | ||
| 556 | estimatedItemSize={tab === "overview" ? 64 : tab === "commits" ? 80 : 220} | ||
| 557 | maintainVisibleContentPosition={false} | ||
| 558 | ListHeaderComponent={ | ||
| 559 | <> | ||
| 560 | <PullRequestReadStatus | ||
| 561 | pending={result.isPending && !identity} | ||
| 562 | error={result.error} | ||
| 563 | /> | 635 | /> |
| 564 | {identity ? ( | 636 | </View> |
| 565 | <View | 637 | ))} |
| 566 | className="gap-5 pb-5" | ||
| 567 | onLayout={(event) => { | ||
| 568 | bodyY.current = event.nativeEvent.layout.height; | ||
| 569 | }} | ||
| 570 | > | ||
| 571 | <PullRequestIdentity pr={identity} params={params} /> | ||
| 572 | <SegmentedControl | ||
| 573 | className="bg-grouped-card" | ||
| 574 | options={[ | ||
| 575 | { value: "overview", label: "Overview" }, | ||
| 576 | { value: "activity", label: "Activity" }, | ||
| 577 | { | ||
| 578 | value: "commits", | ||
| 579 | label: activity.data | ||
| 580 | ? `Commits ${activity.data.commits.length}` | ||
| 581 | : "Commits", | ||
| 582 | }, | ||
| 583 | ]} | ||
| 584 | selected={displayedSection} | ||
| 585 | onSelect={selectSection} | ||
| 586 | role="tab" | ||
| 587 | size="compact" | ||
| 588 | /> | ||
| 589 | </View> | ||
| 590 | ) : null} | ||
| 591 | </> | ||
| 592 | } | ||
| 593 | ListFooterComponent={tab === "overview" ? filesContent.footer : null} | ||
| 594 | /> | ||
| 595 | </View> | 638 | </View> |
| 596 | <PullRequestActionToolbar pr={pr} params={params} onOpenFiles={openFiles} /> | 639 | <PullRequestActionToolbar pr={pr} params={params} onOpenFiles={openFiles} /> |
| 597 | </View> | 640 | </View> |
| ... | @@ -632,13 +675,17 @@ export function PullRequestFileRouteScreen({ | ... | @@ -632,13 +675,17 @@ export function PullRequestFileRouteScreen({ |
| 632 | readonly path: string; | 675 | readonly path: string; |
| 633 | readonly fileSelectionId?: string; | 676 | readonly fileSelectionId?: string; |
| 634 | readonly replacingDraftId?: string; | 677 | readonly replacingDraftId?: string; |
| 678 | readonly focus?: GuideDiffFocus; | ||
| 635 | } | 679 | } |
| 636 | >) { | 680 | >) { |
| 637 | const navigation = useNavigation(); | 681 | const navigation = useNavigation(); |
| 638 | const insets = useSafeAreaInsets(); | 682 | const insets = useSafeAreaInsets(); |
| 639 | const commit = route.params.commit; | 683 | const commit = route.params.commit; |
| 640 | const result = usePullRequestDiff(route.params); | 684 | const result = usePullRequestDiff(route.params); |
| 641 | const filter = usePullRequestFileFilter(result.parsed); | 685 | const filter = usePullRequestFileFilter( |
| 686 | result.parsed, | ||
| 687 | route.params.focus ? route.params.path : undefined, | ||
| 688 | ); | ||
| 642 | const detail = useEnvironmentQuery(composerPullRequests.detail(requestTarget(route.params))); | 689 | const detail = useEnvironmentQuery(composerPullRequests.detail(requestTarget(route.params))); |
| 643 | const { refresh: refreshDetail } = detail; | 690 | const { refresh: refreshDetail } = detail; |
| 644 | useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail])); | 691 | useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail])); |
| ... | @@ -709,6 +756,11 @@ export function PullRequestFileRouteScreen({ | ... | @@ -709,6 +756,11 @@ export function PullRequestFileRouteScreen({ |
| 709 | const targetFile = | 756 | const targetFile = |
| 710 | data.files.find((file) => file.path === jumpPath) ?? | 757 | data.files.find((file) => file.path === jumpPath) ?? |
| 711 | (!jumpPath || requestedFile ? data.files[0] : undefined); | 758 | (!jumpPath || requestedFile ? data.files[0] : undefined); |
| 759 | const focus = route.params.focus; | ||
| 760 | const focusedRows = guideFocusedRows(data.rows, targetFile?.id, focus); | ||
| 761 | const focusIndex = focusedRows.length | ||
| 762 | ? data.rows.findIndex((row) => row.id === focusedRows[0]?.id) | ||
| 763 | : -1; | ||
| 712 | const [collapsedIds, setCollapsedIds] = useState<readonly string[]>(EMPTY_IDS); | 764 | const [collapsedIds, setCollapsedIds] = useState<readonly string[]>(EMPTY_IDS); |
| 713 | const [visibleFileId, setVisibleFileId] = useState<string | null>(null); | 765 | const [visibleFileId, setVisibleFileId] = useState<string | null>(null); |
| 714 | const nativeView = useRef<NativeReviewDiffViewHandle>(null); | 766 | const nativeView = useRef<NativeReviewDiffViewHandle>(null); |
| ... | @@ -722,6 +774,7 @@ export function PullRequestFileRouteScreen({ | ... | @@ -722,6 +774,7 @@ export function PullRequestFileRouteScreen({ |
| 722 | ) | 774 | ) |
| 723 | return; | 775 | return; |
| 724 | lastSelection.current = { path: route.params.path, id: route.params.fileSelectionId }; | 776 | lastSelection.current = { path: route.params.path, id: route.params.fileSelectionId }; |
| 777 | if (route.params.focus) navigation.setParams({ focus: undefined }); | ||
| 725 | setJumpPath(route.params.path); | 778 | setJumpPath(route.params.path); |
| 726 | setSelectedRowId(null); | 779 | setSelectedRowId(null); |
| 727 | jumpStarted.current = false; | 780 | jumpStarted.current = false; |
| ... | @@ -732,7 +785,7 @@ export function PullRequestFileRouteScreen({ | ... | @@ -732,7 +785,7 @@ export function PullRequestFileRouteScreen({ |
| 732 | void nativeView.current.scrollToFile(file.id, false).catch(() => { | 785 | void nativeView.current.scrollToFile(file.id, false).catch(() => { |
| 733 | jumpStarted.current = false; | 786 | jumpStarted.current = false; |
| 734 | }); | 787 | }); |
| 735 | }, [route.params.path, route.params.fileSelectionId, data.files]); | 788 | }, [route.params.path, route.params.fileSelectionId, route.params.focus, data.files, navigation]); |
| 736 | const appliedFilter = useRef(filter.hideTestFiles); | 789 | const appliedFilter = useRef(filter.hideTestFiles); |
| 737 | useEffect(() => { | 790 | useEffect(() => { |
| 738 | if (appliedFilter.current === filter.hideTestFiles) return; | 791 | if (appliedFilter.current === filter.hideTestFiles) return; |
| ... | @@ -745,7 +798,7 @@ export function PullRequestFileRouteScreen({ | ... | @@ -745,7 +798,7 @@ export function PullRequestFileRouteScreen({ |
| 745 | } | 798 | } |
| 746 | }, [filter.hideTestFiles, result.parsed]); | 799 | }, [filter.hideTestFiles, result.parsed]); |
| 747 | const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); | 800 | const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); |
| 748 | const { themeAppearance } = useAppearancePreferences(); | 801 | const { themeAppearance, appearance, setCodeWordBreak } = useAppearancePreferences(); |
| 749 | const bridge = useNativeReviewDiffBridge({ | 802 | const bridge = useNativeReviewDiffBridge({ |
| 750 | threadKey: null, | 803 | threadKey: null, |
| 751 | sectionId: `pull-request:${route.params.repository}:${route.params.number}:${commit ?? "all"}`, | 804 | sectionId: `pull-request:${route.params.repository}:${route.params.number}:${commit ?? "all"}`, |
| ... | @@ -753,7 +806,7 @@ export function PullRequestFileRouteScreen({ | ... | @@ -753,7 +806,7 @@ export function PullRequestFileRouteScreen({ |
| 753 | data, | 806 | data, |
| 754 | collapsedFileIds: collapsedIds, | 807 | collapsedFileIds: collapsedIds, |
| 755 | viewedFileIds: viewedIds, | 808 | viewedFileIds: viewedIds, |
| 756 | selectedRowIds: selectedRowId ? [selectedRowId] : EMPTY_IDS, | 809 | selectedRowIds: selectedRowId ? [selectedRowId] : focusedRows.map((row) => row.id), |
| 757 | canHighlight: true, | 810 | canHighlight: true, |
| 758 | }); | 811 | }); |
| 759 | const [NativeDiffView] = useState(() => resolveNativeReviewDiffView()); | 812 | const [NativeDiffView] = useState(() => resolveNativeReviewDiffView()); |
| ... | @@ -783,6 +836,7 @@ export function PullRequestFileRouteScreen({ | ... | @@ -783,6 +836,7 @@ export function PullRequestFileRouteScreen({ |
| 783 | (Platform.OS !== "ios" || decoded) | 836 | (Platform.OS !== "ios" || decoded) |
| 784 | ) { | 837 | ) { |
| 785 | jumpStarted.current = true; | 838 | jumpStarted.current = true; |
| 839 | if (focusIndex >= 0) return; | ||
| 786 | void nativeView.current.scrollToFile(targetFile.id, false).catch(() => { | 840 | void nativeView.current.scrollToFile(targetFile.id, false).catch(() => { |
| 787 | jumpStarted.current = false; | 841 | jumpStarted.current = false; |
| 788 | }); | 842 | }); |
| ... | @@ -792,7 +846,7 @@ export function PullRequestFileRouteScreen({ | ... | @@ -792,7 +846,7 @@ export function PullRequestFileRouteScreen({ |
| 792 | const lastRow = event.nativeEvent.lastRowIndex; | 846 | const lastRow = event.nativeEvent.lastRowIndex; |
| 793 | if (typeof lastRow === "number" && lastRow >= data.rows.length - 40) loadMore(); | 847 | if (typeof lastRow === "number" && lastRow >= data.rows.length - 40) loadMore(); |
| 794 | }, | 848 | }, |
| 795 | [updateVisibleRange, data.rows.length, loadMore, targetFile], | 849 | [updateVisibleRange, data.rows.length, loadMore, targetFile, focusIndex], |
| 796 | ); | 850 | ); |
| 797 | const onToggleFile = useCallback<NonNullable<NativeReviewDiffViewProps["onToggleFile"]>>( | 851 | const onToggleFile = useCallback<NonNullable<NativeReviewDiffViewProps["onToggleFile"]>>( |
| 798 | (event) => { | 852 | (event) => { |
| ... | @@ -829,6 +883,12 @@ export function PullRequestFileRouteScreen({ | ... | @@ -829,6 +883,12 @@ export function PullRequestFileRouteScreen({ |
| 829 | trailing={ | 883 | trailing={ |
| 830 | <ControlPillMenu | 884 | <ControlPillMenu |
| 831 | actions={[ | 885 | actions={[ |
| 886 | { | ||
| 887 | id: "word-wrap", | ||
| 888 | title: "Word wrap", | ||
| 889 | image: "text.alignleft", | ||
| 890 | state: appearance.codeWordBreak ? "on" : "off", | ||
| 891 | }, | ||
| 832 | { | 892 | { |
| 833 | id: "hide-tests", | 893 | id: "hide-tests", |
| 834 | title: "Hide test files", | 894 | title: "Hide test files", |
| ... | @@ -849,7 +909,8 @@ export function PullRequestFileRouteScreen({ | ... | @@ -849,7 +909,8 @@ export function PullRequestFileRouteScreen({ |
| 849 | : []), | 909 | : []), |
| 850 | ]} | 910 | ]} |
| 851 | onPressAction={({ nativeEvent }) => { | 911 | onPressAction={({ nativeEvent }) => { |
| 852 | if (nativeEvent.event === "hide-tests") toggleTestFiles(); | 912 | if (nativeEvent.event === "word-wrap") setCodeWordBreak(!appearance.codeWordBreak); |
| 913 | else if (nativeEvent.event === "hide-tests") toggleTestFiles(); | ||
| 853 | else if ( | 914 | else if ( |
| 854 | nativeEvent.event === "submit-review" || | 915 | nativeEvent.event === "submit-review" || |
| 855 | nativeEvent.event === "comment-on-pr" | 916 | nativeEvent.event === "comment-on-pr" |
| ... | @@ -895,6 +956,13 @@ export function PullRequestFileRouteScreen({ | ... | @@ -895,6 +956,13 @@ export function PullRequestFileRouteScreen({ |
| 895 | <NativeHeaderToolbar.Label>Add comment</NativeHeaderToolbar.Label> | 956 | <NativeHeaderToolbar.Label>Add comment</NativeHeaderToolbar.Label> |
| 896 | </NativeHeaderToolbar.MenuAction> | 957 | </NativeHeaderToolbar.MenuAction> |
| 897 | ) : null} | 958 | ) : null} |
| 959 | <NativeHeaderToolbar.MenuAction | ||
| 960 | icon="text.alignleft" | ||
| 961 | isOn={appearance.codeWordBreak} | ||
| 962 | onPress={() => setCodeWordBreak(!appearance.codeWordBreak)} | ||
| 963 | > | ||
| 964 | <NativeHeaderToolbar.Label>Word wrap</NativeHeaderToolbar.Label> | ||
| 965 | </NativeHeaderToolbar.MenuAction> | ||
| 898 | <NativeHeaderToolbar.MenuAction | 966 | <NativeHeaderToolbar.MenuAction |
| 899 | icon="line.3.horizontal.decrease" | 967 | icon="line.3.horizontal.decrease" |
| 900 | isOn={filter.hideTestFiles} | 968 | isOn={filter.hideTestFiles} |
| ... | @@ -956,6 +1024,7 @@ export function PullRequestFileRouteScreen({ | ... | @@ -956,6 +1024,7 @@ export function PullRequestFileRouteScreen({ |
| 956 | {NativeDiffView && data.rows.length && !seekingFile ? ( | 1024 | {NativeDiffView && data.rows.length && !seekingFile ? ( |
| 957 | <View className="flex-1" collapsable={false}> | 1025 | <View className="flex-1" collapsable={false}> |
| 958 | <NativeDiffView | 1026 | <NativeDiffView |
| 1027 | initialRowIndex={focusIndex >= 0 ? Math.max(0, focusIndex - 3) : undefined} | ||
| 959 | nativeViewRef={nativeView} | 1028 | nativeViewRef={nativeView} |
| 960 | refreshing={result.refreshing} | 1029 | refreshing={result.refreshing} |
| 961 | onPullToRefresh={result.onPullRefresh} | 1030 | onPullToRefresh={result.onPullRefresh} |
apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx+20-8| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useNavigation } from "@react-navigation/native"; | 2 | import { useNavigation } from "@react-navigation/native"; |
| 3 | import type { PullRequestAction, PullRequestDetail } from "@t3tools/contracts"; | 3 | import type { PullRequestAction, PullRequestDetail } from "@t3tools/contracts"; |
| 4 | import Animated from "react-native-reanimated"; | ||
| 5 | import { PR_ROW_LAYOUT, PR_DISCLOSURE_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations"; | ||
| 4 | import { useState } from "react"; | 6 | import { useState } from "react"; |
| 5 | import { Linking, Pressable, View } from "react-native"; | 7 | import { Linking, Pressable, View } from "react-native"; |
| 6 | import { AppText as Text } from "../../components/AppText"; | 8 | import { AppText as Text } from "../../components/AppText"; |
| ... | @@ -97,7 +99,7 @@ export function PullRequestChecksRow(props: RowProps) { | ... | @@ -97,7 +99,7 @@ export function PullRequestChecksRow(props: RowProps) { |
| 97 | const checks = pullRequestChecksSummary(props.pr); | 99 | const checks = pullRequestChecksSummary(props.pr); |
| 98 | const action = usePullRequestAction(props.params, props.pr); | 100 | const action = usePullRequestAction(props.params, props.pr); |
| 99 | return ( | 101 | return ( |
| 100 | <> | 102 | <Animated.View layout={PR_ROW_LAYOUT}> |
| 101 | <PullRequestCardRow | 103 | <PullRequestCardRow |
| 102 | title={checks.title} | 104 | title={checks.title} |
| 103 | icon={checks.icon} | 105 | icon={checks.icon} |
| ... | @@ -109,7 +111,12 @@ export function PullRequestChecksRow(props: RowProps) { | ... | @@ -109,7 +111,12 @@ export function PullRequestChecksRow(props: RowProps) { |
| 109 | onPress={() => setExpanded(!expanded)} | 111 | onPress={() => setExpanded(!expanded)} |
| 110 | /> | 112 | /> |
| 111 | {expanded ? ( | 113 | {expanded ? ( |
| 112 | <View className="pl-8"> | 114 | <Animated.View |
| 115 | layout={PR_ROW_LAYOUT} | ||
| 116 | entering={PR_DISCLOSURE_ENTER} | ||
| 117 | exiting={PR_ROW_EXIT} | ||
| 118 | className="pl-8" | ||
| 119 | > | ||
| 113 | {props.pr.state === "open" && | 120 | {props.pr.state === "open" && |
| 114 | props.pr.checks.some((check) => check.status === "failure") ? ( | 121 | props.pr.checks.some((check) => check.status === "failure") ? ( |
| 115 | <PullRequestAgentRow {...props} task="feedback" title="Fix failing checks" /> | 122 | <PullRequestAgentRow {...props} task="feedback" title="Fix failing checks" /> |
| ... | @@ -157,9 +164,9 @@ export function PullRequestChecksRow(props: RowProps) { | ... | @@ -157,9 +164,9 @@ export function PullRequestChecksRow(props: RowProps) { |
| 157 | ) | 164 | ) |
| 158 | } | 165 | } |
| 159 | /> | 166 | /> |
| 160 | </View> | 167 | </Animated.View> |
| 161 | ) : null} | 168 | ) : null} |
| 162 | </> | 169 | </Animated.View> |
| 163 | ); | 170 | ); |
| 164 | } | 171 | } |
| 165 | 172 | ||
| ... | @@ -236,7 +243,7 @@ export function PullRequestReviewersRow(props: RowProps) { | ... | @@ -236,7 +243,7 @@ export function PullRequestReviewersRow(props: RowProps) { |
| 236 | props.pr.capabilities.reviewers.request && | 243 | props.pr.capabilities.reviewers.request && |
| 237 | props.pr.viewerPermissions.requestReviewers; | 244 | props.pr.viewerPermissions.requestReviewers; |
| 238 | return ( | 245 | return ( |
| 239 | <> | 246 | <Animated.View layout={PR_ROW_LAYOUT}> |
| 240 | <PullRequestCardRow | 247 | <PullRequestCardRow |
| 241 | title={summary.title} | 248 | title={summary.title} |
| 242 | icon={summary.icon} | 249 | icon={summary.icon} |
| ... | @@ -247,7 +254,12 @@ export function PullRequestReviewersRow(props: RowProps) { | ... | @@ -247,7 +254,12 @@ export function PullRequestReviewersRow(props: RowProps) { |
| 247 | onPress={() => setExpanded(!expanded)} | 254 | onPress={() => setExpanded(!expanded)} |
| 248 | /> | 255 | /> |
| 249 | {expanded ? ( | 256 | {expanded ? ( |
| 250 | <View className="pl-8"> | 257 | <Animated.View |
| 258 | layout={PR_ROW_LAYOUT} | ||
| 259 | entering={PR_DISCLOSURE_ENTER} | ||
| 260 | exiting={PR_ROW_EXIT} | ||
| 261 | className="pl-8" | ||
| 262 | > | ||
| 251 | {summary.reviews.map((review) => ( | 263 | {summary.reviews.map((review) => ( |
| 252 | <View | 264 | <View |
| 253 | key={review.id} | 265 | key={review.id} |
| ... | @@ -308,8 +320,8 @@ export function PullRequestReviewersRow(props: RowProps) { | ... | @@ -308,8 +320,8 @@ export function PullRequestReviewersRow(props: RowProps) { |
| 308 | onPress={() => void Linking.openURL(props.pr.url)} | 320 | onPress={() => void Linking.openURL(props.pr.url)} |
| 309 | /> | 321 | /> |
| 310 | )} | 322 | )} |
| 311 | </View> | 323 | </Animated.View> |
| 312 | ) : null} | 324 | ) : null} |
| 313 | </> | 325 | </Animated.View> |
| 314 | ); | 326 | ); |
| 315 | } | 327 | } |
apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx created+115| ... | @@ -0,0 +1,115 @@ | ||
| 1 | import type { PullRequestReviewGuide } from "@t3tools/contracts"; | ||
| 2 | import { stopFilePaths } from "@t3tools/client-runtime/review-guide-tour"; | ||
| 3 | import { Pressable, View } from "react-native"; | ||
| 4 | import { AppText as Text } from "../../../components/AppText"; | ||
| 5 | import { SymbolView } from "../../../components/AppSymbol"; | ||
| 6 | import { PullRequestMarkdown } from "../PullRequestMarkdown"; | ||
| 7 | import { ControlPillMenu } from "../../../components/ControlPillMenu"; | ||
| 8 | |||
| 9 | import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; | ||
| 10 | |||
| 11 | /** The stops are a reading sequence, with file context kept beside each idea. */ | ||
| 12 | export function GuideOutline({ | ||
| 13 | guide, | ||
| 14 | onOpen, | ||
| 15 | onRewrite, | ||
| 16 | }: { | ||
| 17 | readonly guide: PullRequestReviewGuide; | ||
| 18 | readonly onOpen: (position: number) => void; | ||
| 19 | readonly onRewrite?: () => void; | ||
| 20 | }) { | ||
| 21 | const { appearance, setCodeWordBreak } = useAppearancePreferences(); | ||
| 22 | const files = new Set(guide.stops.flatMap(stopFilePaths)); | ||
| 23 | return ( | ||
| 24 | <View> | ||
| 25 | <View className="mb-5 flex-row items-center justify-between"> | ||
| 26 | <View className="flex-row items-baseline gap-2"> | ||
| 27 | <Text className="text-base font-t3-semibold">{guide.stops.length} stops</Text> | ||
| 28 | <Text className="text-sm text-foreground-muted"> | ||
| 29 | through {files.size} {files.size === 1 ? "file" : "files"} | ||
| 30 | </Text> | ||
| 31 | </View> | ||
| 32 | <ControlPillMenu | ||
| 33 | title="Guide options" | ||
| 34 | actions={[ | ||
| 35 | { | ||
| 36 | id: "word-wrap", | ||
| 37 | title: "Word wrap", | ||
| 38 | image: "text.alignleft", | ||
| 39 | state: appearance.codeWordBreak ? "on" : "off", | ||
| 40 | }, | ||
| 41 | ...(onRewrite | ||
| 42 | ? [{ id: "rewrite", title: "Rewrite guide", image: "arrow.clockwise" }] | ||
| 43 | : []), | ||
| 44 | ]} | ||
| 45 | onPressAction={({ nativeEvent }) => | ||
| 46 | nativeEvent.event === "word-wrap" | ||
| 47 | ? setCodeWordBreak(!appearance.codeWordBreak) | ||
| 48 | : onRewrite?.() | ||
| 49 | } | ||
| 50 | > | ||
| 51 | <Pressable | ||
| 52 | accessibilityRole="button" | ||
| 53 | accessibilityLabel="Guide options" | ||
| 54 | className="h-11 w-11 items-center justify-center rounded-full active:bg-grouped-card" | ||
| 55 | > | ||
| 56 | <SymbolView name="ellipsis" size={20} tintColorClassName="accent-icon-muted" /> | ||
| 57 | </Pressable> | ||
| 58 | </ControlPillMenu> | ||
| 59 | </View> | ||
| 60 | {guide.stops[0]?.summary ? ( | ||
| 61 | <View className="mb-6"> | ||
| 62 | <PullRequestMarkdown markdown={guide.stops[0].summary} /> | ||
| 63 | </View> | ||
| 64 | ) : null} | ||
| 65 | {guide.stops.map((stop, position) => { | ||
| 66 | const paths = stopFilePaths(stop); | ||
| 67 | const firstPath = paths[0]; | ||
| 68 | const filename = firstPath?.split("/").at(-1); | ||
| 69 | return ( | ||
| 70 | <Pressable | ||
| 71 | key={`${guide.headSha}:${JSON.stringify(stop)}`} | ||
| 72 | accessibilityRole="button" | ||
| 73 | accessibilityLabel={`Stop ${position + 1}: ${stop.title}`} | ||
| 74 | onPress={() => onOpen(position)} | ||
| 75 | className="flex-row gap-4 active:opacity-60" | ||
| 76 | > | ||
| 77 | <View className="w-8 items-center"> | ||
| 78 | <View className="h-8 w-8 items-center justify-center rounded-full border border-border bg-grouped-card"> | ||
| 79 | <Text className="text-sm font-t3-medium tabular-nums">{position + 1}</Text> | ||
| 80 | </View> | ||
| 81 | {position < guide.stops.length - 1 ? ( | ||
| 82 | <View className="min-h-5 w-px flex-1 bg-border-subtle" /> | ||
| 83 | ) : null} | ||
| 84 | </View> | ||
| 85 | <View className="min-w-0 flex-1 gap-2 pb-7 pt-0.5"> | ||
| 86 | <Text className="text-base leading-6 font-t3-medium">{stop.title}</Text> | ||
| 87 | <View className="flex-row items-center gap-1.5"> | ||
| 88 | {stop.attention ? ( | ||
| 89 | <SymbolView name="eye" size={14} tintColorClassName="accent-warning-foreground" /> | ||
| 90 | ) : null} | ||
| 91 | <Text className="shrink text-xs text-foreground-muted" numberOfLines={1}> | ||
| 92 | {filename ?? "Context"} | ||
| 93 | {paths.length > 1 ? ` +${paths.length - 1}` : ""} | ||
| 94 | </Text> | ||
| 95 | </View> | ||
| 96 | </View> | ||
| 97 | <View className="pt-2"> | ||
| 98 | <SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-muted" /> | ||
| 99 | </View> | ||
| 100 | </Pressable> | ||
| 101 | ); | ||
| 102 | })} | ||
| 103 | {guide.stops.length ? ( | ||
| 104 | <Pressable | ||
| 105 | accessibilityRole="button" | ||
| 106 | onPress={() => onOpen(0)} | ||
| 107 | className="min-h-11 flex-row items-center justify-end gap-2 active:opacity-60" | ||
| 108 | > | ||
| 109 | <Text className="text-sm font-t3-medium">Start tour</Text> | ||
| 110 | <SymbolView name="arrow.right" size={17} tintColorClassName="accent-foreground" /> | ||
| 111 | </Pressable> | ||
| 112 | ) : null} | ||
| 113 | </View> | ||
| 114 | ); | ||
| 115 | } | ||
apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx created+400| ... | @@ -0,0 +1,400 @@ | ||
| 1 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; | ||
| 2 | import type { FileDiffMetadata } from "@pierre/diffs"; | ||
| 3 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | ||
| 4 | import { useEffect, useMemo } from "react"; | ||
| 5 | import { Platform, Pressable, View } from "react-native"; | ||
| 6 | import { AndroidScreenHeader } from "../../../components/AndroidScreenHeader"; | ||
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 8 | import { | ||
| 9 | letterStopNotes, | ||
| 10 | stepTourPosition, | ||
| 11 | stopFilePaths, | ||
| 12 | } from "@t3tools/client-runtime/review-guide-tour"; | ||
| 13 | import { SymbolView } from "../../../components/AppSymbol"; | ||
| 14 | import { GuideOutline } from "./GuideOutline"; | ||
| 15 | import { AppText as Text } from "../../../components/AppText"; | ||
| 16 | import { MaterialButton } from "../../../components/MaterialButton"; | ||
| 17 | import { ScreenScrollView } from "../../../components/ScreenScrollView"; | ||
| 18 | import { ControlPillMenu } from "../../../components/ControlPillMenu"; | ||
| 19 | import { MaterialIconButton } from "../../../components/MaterialIconButton"; | ||
| 20 | import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; | ||
| 21 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../../native/StackHeader"; | ||
| 22 | import { PullRequestCardGroup, PullRequestCardRow } from "../PullRequestControls"; | ||
| 23 | import { PullRequestMarkdown, PullRequestMarkdownRepository } from "../PullRequestMarkdown"; | ||
| 24 | import { usePullRequestDiff, type PullRequestParams } from "../usePullRequestDiff"; | ||
| 25 | import { ReviewGuideExcerpt } from "./ReviewGuideExcerpt"; | ||
| 26 | import { reviewGuideState } from "./reviewGuideState"; | ||
| 27 | import { useEnvironmentQuery } from "../../../state/query"; | ||
| 28 | import { composerPullRequests } from "../../../state/pull-requests"; | ||
| 29 | import { usePullRequestPermissions } from "../usePullRequestPermissions"; | ||
| 30 | import { pullRequestTarget } from "../usePullRequestDiff"; | ||
| 31 | import { useReviewGuide } from "./useReviewGuide"; | ||
| 32 | |||
| 33 | export type GuideStopParams = PullRequestParams & { | ||
| 34 | readonly position: number; | ||
| 35 | readonly guideHeadSha: string; | ||
| 36 | readonly guideGeneratedAt: string; | ||
| 37 | }; | ||
| 38 | |||
| 39 | const attentionLabels = { | ||
| 40 | tradeoff: "A tradeoff to weigh", | ||
| 41 | contract: "A contract to check", | ||
| 42 | product: "A product call", | ||
| 43 | risk: "A risk to judge", | ||
| 44 | accessibility: "An accessibility call", | ||
| 45 | }; | ||
| 46 | |||
| 47 | function GuideNotice({ state }: { readonly state: ReturnType<typeof useReviewGuide> }) { | ||
| 48 | const { guide, stale, failure } = reviewGuideState(state.data); | ||
| 49 | const error = state.startError ?? failure ?? state.error; | ||
| 50 | const label = state.running | ||
| 51 | ? guide | ||
| 52 | ? "Rewriting review guide" | ||
| 53 | : "Writing review guide" | ||
| 54 | : error | ||
| 55 | ? "Could not write guide" | ||
| 56 | : stale | ||
| 57 | ? "Guide is out of date" | ||
| 58 | : "No review guide yet"; | ||
| 59 | if (guide && !stale && !error && !state.running) return null; | ||
| 60 | return ( | ||
| 61 | <View | ||
| 62 | className={guide ? "gap-3 border-l-2 border-border pl-4 py-1" : "gap-4 py-4"} | ||
| 63 | accessibilityLiveRegion="polite" | ||
| 64 | > | ||
| 65 | <View className="flex-row items-center gap-2"> | ||
| 66 | <SymbolView | ||
| 67 | name={ | ||
| 68 | state.running | ||
| 69 | ? "clock" | ||
| 70 | : error | ||
| 71 | ? "exclamationmark.circle" | ||
| 72 | : stale | ||
| 73 | ? "arrow.clockwise" | ||
| 74 | : "list.number" | ||
| 75 | } | ||
| 76 | size={20} | ||
| 77 | tintColorClassName={ | ||
| 78 | error | ||
| 79 | ? "accent-danger-foreground" | ||
| 80 | : stale | ||
| 81 | ? "accent-warning-foreground" | ||
| 82 | : "accent-icon-muted" | ||
| 83 | } | ||
| 84 | /> | ||
| 85 | <Text className={guide ? "text-base font-t3-semibold" : "text-xl font-t3-semibold"}> | ||
| 86 | {label} | ||
| 87 | </Text> | ||
| 88 | </View> | ||
| 89 | <Text className="text-sm leading-6 text-foreground-muted"> | ||
| 90 | {error ?? | ||
| 91 | (state.running | ||
| 92 | ? "An agent is reading the change. You can leave this screen and return when it’s ready." | ||
| 93 | : stale | ||
| 94 | ? "There are new changes since this guide was written. Rewrite it to see the latest code." | ||
| 95 | : "Walk through the important code in order, with notes on what changed and where to look closely.")} | ||
| 96 | </Text> | ||
| 97 | {state.running ? ( | ||
| 98 | <Text className="text-xs text-foreground-muted"> | ||
| 99 | {state.data?.run?._tag === "Running" | ||
| 100 | ? `Started at ${new Date(state.data.run.startedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` | ||
| 101 | : "Starting the guide…"} | ||
| 102 | </Text> | ||
| 103 | ) : state.canWrite ? ( | ||
| 104 | <MaterialButton | ||
| 105 | label={error ? "Try again" : guide ? "Rewrite guide" : "Write guide"} | ||
| 106 | tone={guide ? "secondary" : "contrast"} | ||
| 107 | onPress={() => void state.write()} | ||
| 108 | /> | ||
| 109 | ) : null} | ||
| 110 | </View> | ||
| 111 | ); | ||
| 112 | } | ||
| 113 | |||
| 114 | export function PullRequestGuideContent({ params }: { readonly params: PullRequestParams }) { | ||
| 115 | const navigation = useNavigation(); | ||
| 116 | const state = useReviewGuide(params); | ||
| 117 | const { guide, stale } = reviewGuideState(state.data); | ||
| 118 | return ( | ||
| 119 | <View className="w-full max-w-[760px] self-center gap-5"> | ||
| 120 | {!state.enabled ? ( | ||
| 121 | <Text>Review guides are disabled for this server.</Text> | ||
| 122 | ) : ( | ||
| 123 | <> | ||
| 124 | {state.isPending && !state.data ? ( | ||
| 125 | <Text className="text-foreground-muted">Loading review guide…</Text> | ||
| 126 | ) : ( | ||
| 127 | <GuideNotice state={state} /> | ||
| 128 | )} | ||
| 129 | {guide ? ( | ||
| 130 | <GuideOutline | ||
| 131 | guide={guide} | ||
| 132 | onOpen={(position) => | ||
| 133 | navigation.dispatch( | ||
| 134 | StackActions.push("PullRequestGuideStop", { | ||
| 135 | ...params, | ||
| 136 | position, | ||
| 137 | guideHeadSha: guide.headSha, | ||
| 138 | guideGeneratedAt: guide.generatedAt, | ||
| 139 | }), | ||
| 140 | ) | ||
| 141 | } | ||
| 142 | onRewrite={ | ||
| 143 | state.canWrite && !state.running && !stale && !state.startError && !state.error | ||
| 144 | ? () => void state.write() | ||
| 145 | : undefined | ||
| 146 | } | ||
| 147 | /> | ||
| 148 | ) : null} | ||
| 149 | </> | ||
| 150 | )} | ||
| 151 | </View> | ||
| 152 | ); | ||
| 153 | } | ||
| 154 | |||
| 155 | export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideStopParams>) { | ||
| 156 | const navigation = useNavigation(); | ||
| 157 | const state = useReviewGuide(route.params); | ||
| 158 | const detail = useEnvironmentQuery(composerPullRequests.detail(pullRequestTarget(route.params))); | ||
| 159 | const permissions = usePullRequestPermissions(route.params, detail.data); | ||
| 160 | const canReview = | ||
| 161 | permissions.verdicts.length > 0 && detail.data?.headSha === route.params.guideHeadSha; | ||
| 162 | const { guide, stale } = reviewGuideState(state.data); | ||
| 163 | const position = Math.max(0, Math.min(route.params.position, (guide?.stops.length ?? 1) - 1)); | ||
| 164 | const stop = guide?.stops[Math.max(0, position)]; | ||
| 165 | const diff = usePullRequestDiff(route.params); | ||
| 166 | const excerptFiles = useMemo(() => { | ||
| 167 | const files = new Map<string, FileDiffMetadata>(); | ||
| 168 | try { | ||
| 169 | for (const patch of parsePatchFiles(diff.patch)) { | ||
| 170 | for (const file of patch.files) { | ||
| 171 | files.set(file.name, file); | ||
| 172 | if (file.prevName) files.set(file.prevName, file); | ||
| 173 | } | ||
| 174 | } | ||
| 175 | } catch { | ||
| 176 | // An omitted or unparseable patch is shown as unavailable by the excerpt. | ||
| 177 | } | ||
| 178 | return files; | ||
| 179 | }, [diff.patch]); | ||
| 180 | const wanted = useMemo(() => (stop ? stopFilePaths(stop) : []), [stop]); | ||
| 181 | const loadedPaths = new Set( | ||
| 182 | diff.parsed.kind === "files" ? diff.parsed.files.map((file) => file.path) : [], | ||
| 183 | ); | ||
| 184 | const missing = wanted.some((path) => !loadedPaths.has(path)); | ||
| 185 | const { error: diffError, loadMore } = diff; | ||
| 186 | useEffect(() => { | ||
| 187 | if (state.enabled && missing && !diffError) loadMore(); | ||
| 188 | }, [state.enabled, missing, diffError, loadMore]); | ||
| 189 | const insets = useSafeAreaInsets(); | ||
| 190 | const changedGuide = | ||
| 191 | guide !== null && | ||
| 192 | (guide.headSha !== route.params.guideHeadSha || | ||
| 193 | guide.generatedAt !== route.params.guideGeneratedAt); | ||
| 194 | const { appearance, setCodeWordBreak } = useAppearancePreferences(); | ||
| 195 | const notes = stop ? letterStopNotes(stop).lettered : []; | ||
| 196 | return ( | ||
| 197 | <View className="flex-1 bg-screen"> | ||
| 198 | <NativeStackScreenOptions | ||
| 199 | options={{ | ||
| 200 | title: `Stop ${route.params.position + 1}`, | ||
| 201 | headerTitle: `Stop ${route.params.position + 1} of ${guide?.stops.length ?? "…"}`, | ||
| 202 | }} | ||
| 203 | /> | ||
| 204 | {Platform.OS === "android" ? ( | ||
| 205 | <AndroidScreenHeader | ||
| 206 | title={`Stop ${route.params.position + 1}`} | ||
| 207 | onBack={() => navigation.goBack()} | ||
| 208 | trailing={ | ||
| 209 | <ControlPillMenu | ||
| 210 | actions={[ | ||
| 211 | { | ||
| 212 | id: "word-wrap", | ||
| 213 | title: "Word wrap", | ||
| 214 | image: "text.alignleft", | ||
| 215 | state: appearance.codeWordBreak ? "on" : "off", | ||
| 216 | }, | ||
| 217 | ]} | ||
| 218 | onPressAction={() => setCodeWordBreak(!appearance.codeWordBreak)} | ||
| 219 | > | ||
| 220 | <View pointerEvents="none"> | ||
| 221 | <MaterialIconButton icon="ellipsis" accessibilityLabel="Guide options" /> | ||
| 222 | </View> | ||
| 223 | </ControlPillMenu> | ||
| 224 | } | ||
| 225 | /> | ||
| 226 | ) : null} | ||
| 227 | <NativeHeaderToolbar placement="right"> | ||
| 228 | <NativeHeaderToolbar.Menu | ||
| 229 | title="Guide options" | ||
| 230 | accessibilityLabel="Guide options" | ||
| 231 | icon="ellipsis" | ||
| 232 | > | ||
| 233 | <NativeHeaderToolbar.MenuAction | ||
| 234 | icon="text.alignleft" | ||
| 235 | isOn={appearance.codeWordBreak} | ||
| 236 | onPress={() => setCodeWordBreak(!appearance.codeWordBreak)} | ||
| 237 | > | ||
| 238 | <NativeHeaderToolbar.Label>Word wrap</NativeHeaderToolbar.Label> | ||
| 239 | </NativeHeaderToolbar.MenuAction> | ||
| 240 | </NativeHeaderToolbar.Menu> | ||
| 241 | </NativeHeaderToolbar> | ||
| 242 | <PullRequestMarkdownRepository | ||
| 243 | value={`https://${route.params.host ?? "github.com"}/${route.params.repository}`} | ||
| 244 | > | ||
| 245 | <ScreenScrollView | ||
| 246 | contentInsetAdjustmentBehavior="automatic" | ||
| 247 | contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 24, gap: 20 }} | ||
| 248 | > | ||
| 249 | <View className="w-full max-w-[760px] self-center gap-5"> | ||
| 250 | {!state.enabled ? ( | ||
| 251 | <Text>Review guides are disabled for this server.</Text> | ||
| 252 | ) : changedGuide ? ( | ||
| 253 | <Text>This guide was rewritten. Go back to start the new tour.</Text> | ||
| 254 | ) : !stop ? ( | ||
| 255 | <Text>{state.error ?? "Loading stop…"}</Text> | ||
| 256 | ) : ( | ||
| 257 | <> | ||
| 258 | <GuideNotice state={state} /> | ||
| 259 | <Text className="text-2xl leading-8 font-t3-semibold">{stop.title}</Text> | ||
| 260 | <PullRequestMarkdown markdown={stop.summary} /> | ||
| 261 | {stop.attention ? ( | ||
| 262 | <View className="gap-3 rounded-xl border border-border bg-grouped-card p-4"> | ||
| 263 | <View className="flex-row items-center gap-2"> | ||
| 264 | <SymbolView name="eye" size={17} tintColorClassName="accent-icon-muted" /> | ||
| 265 | <Text className="text-sm font-t3-semibold"> | ||
| 266 | {attentionLabels[stop.attention.kind]} | ||
| 267 | </Text> | ||
| 268 | </View> | ||
| 269 | <PullRequestMarkdown markdown={stop.attention.note} /> | ||
| 270 | </View> | ||
| 271 | ) : null} | ||
| 272 | {stale ? ( | ||
| 273 | <Text className="text-sm text-foreground-muted"> | ||
| 274 | Excerpts are hidden until the guide is rewritten for the current head. | ||
| 275 | </Text> | ||
| 276 | ) : ( | ||
| 277 | stop.excerpts.map((excerpt) => ( | ||
| 278 | <ReviewGuideExcerpt | ||
| 279 | key={`${guide?.headSha}:${JSON.stringify(excerpt)}`} | ||
| 280 | onOpen={() => | ||
| 281 | navigation.dispatch( | ||
| 282 | StackActions.push("PullRequestFile", { | ||
| 283 | ...route.params, | ||
| 284 | path: excerpt.path, | ||
| 285 | focus: { side: excerpt.side, start: excerpt.start, end: excerpt.end }, | ||
| 286 | fileSelectionId: `guide:${position}:${excerpt.path}:${excerpt.start}`, | ||
| 287 | }), | ||
| 288 | ) | ||
| 289 | } | ||
| 290 | excerpt={excerpt} | ||
| 291 | file={excerptFiles.get(excerpt.path)} | ||
| 292 | loading={diff.isPending || (missing && diff.nextCursor !== null)} | ||
| 293 | /> | ||
| 294 | )) | ||
| 295 | )} | ||
| 296 | {diff.error ? <Text className="text-danger-foreground">{diff.error}</Text> : null} | ||
| 297 | {notes.length ? ( | ||
| 298 | <View className="gap-4"> | ||
| 299 | <Text className="font-t3-semibold">Notes on this stop</Text> | ||
| 300 | {notes.map(({ marker, note }) => ( | ||
| 301 | <View key={marker} className="flex-row gap-3"> | ||
| 302 | <View className="h-7 w-7 items-center justify-center rounded-lg bg-grouped-card"> | ||
| 303 | <Text className="text-xs font-t3-semibold">{marker}</Text> | ||
| 304 | </View> | ||
| 305 | <View className="min-w-0 flex-1 gap-2"> | ||
| 306 | <Text className="font-t3-semibold">{note.title}</Text> | ||
| 307 | <Text className="text-xs text-foreground-muted"> | ||
| 308 | {note.path.split("/").at(-1)}:{note.line} | ||
| 309 | </Text> | ||
| 310 | <PullRequestMarkdown markdown={note.body} /> | ||
| 311 | </View> | ||
| 312 | </View> | ||
| 313 | ))} | ||
| 314 | </View> | ||
| 315 | ) : null} | ||
| 316 | {stop.files.length ? ( | ||
| 317 | <PullRequestCardGroup> | ||
| 318 | {stop.files.map((file, index) => ( | ||
| 319 | <PullRequestCardRow | ||
| 320 | key={file.path} | ||
| 321 | title={file.path} | ||
| 322 | detail={file.note} | ||
| 323 | icon="doc" | ||
| 324 | last={index === stop.files.length - 1} | ||
| 325 | onPress={() => | ||
| 326 | navigation.navigate("PullRequestFile", { | ||
| 327 | ...route.params, | ||
| 328 | path: file.path, | ||
| 329 | }) | ||
| 330 | } | ||
| 331 | /> | ||
| 332 | ))} | ||
| 333 | </PullRequestCardGroup> | ||
| 334 | ) : null} | ||
| 335 | {stop.questions.length ? ( | ||
| 336 | <View className="gap-3"> | ||
| 337 | <Text className="font-t3-semibold">Questions to consider</Text> | ||
| 338 | {stop.questions.map((question) => ( | ||
| 339 | <PullRequestMarkdown key={question} markdown={question} /> | ||
| 340 | ))} | ||
| 341 | </View> | ||
| 342 | ) : null} | ||
| 343 | <Pressable | ||
| 344 | accessibilityRole="button" | ||
| 345 | accessibilityLabel={ | ||
| 346 | guide && position < guide.stops.length - 1 | ||
| 347 | ? "Next stop" | ||
| 348 | : canReview | ||
| 349 | ? "Finish review" | ||
| 350 | : "Finish tour" | ||
| 351 | } | ||
| 352 | className="mt-2 flex-row items-center gap-4 border-t border-border-subtle pt-5 pb-2 active:opacity-60" | ||
| 353 | onPress={() => | ||
| 354 | guide && position < guide.stops.length - 1 | ||
| 355 | ? navigation.dispatch( | ||
| 356 | StackActions.push("PullRequestGuideStop", { | ||
| 357 | ...route.params, | ||
| 358 | position: stepTourPosition(position, 1, guide.stops.length), | ||
| 359 | }), | ||
| 360 | ) | ||
| 361 | : canReview | ||
| 362 | ? navigation.navigate("PullRequestReview", { | ||
| 363 | ...route.params, | ||
| 364 | mode: "review", | ||
| 365 | }) | ||
| 366 | : navigation.dispatch(StackActions.popTo("PullRequest", route.params)) | ||
| 367 | } | ||
| 368 | > | ||
| 369 | <View className="min-w-0 flex-1 gap-1"> | ||
| 370 | <Text className="text-xs text-foreground-muted"> | ||
| 371 | {guide && position < guide.stops.length - 1 | ||
| 372 | ? `Next · ${position + 2} of ${guide.stops.length}` | ||
| 373 | : "End of guide"} | ||
| 374 | </Text> | ||
| 375 | <Text className="text-base font-t3-semibold"> | ||
| 376 | {guide && position < guide.stops.length - 1 | ||
| 377 | ? guide.stops[position + 1]?.title | ||
| 378 | : canReview | ||
| 379 | ? "Finish review" | ||
| 380 | : "Finish tour"} | ||
| 381 | </Text> | ||
| 382 | </View> | ||
| 383 | <View className="h-11 w-11 items-center justify-center rounded-full bg-foreground"> | ||
| 384 | <SymbolView | ||
| 385 | name={ | ||
| 386 | guide && position < guide.stops.length - 1 ? "arrow.right" : "checkmark" | ||
| 387 | } | ||
| 388 | size={20} | ||
| 389 | tintColorClassName="accent-screen" | ||
| 390 | /> | ||
| 391 | </View> | ||
| 392 | </Pressable> | ||
| 393 | </> | ||
| 394 | )} | ||
| 395 | </View> | ||
| 396 | </ScreenScrollView> | ||
| 397 | </PullRequestMarkdownRepository> | ||
| 398 | </View> | ||
| 399 | ); | ||
| 400 | } | ||
apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx created+135| ... | @@ -0,0 +1,135 @@ | ||
| 1 | import { useMemo, useState } from "react"; | ||
| 2 | import { Pressable, View } from "react-native"; | ||
| 3 | import type { FileDiffMetadata } from "@pierre/diffs"; | ||
| 4 | import type { PullRequestReviewGuideExcerpt } from "@t3tools/contracts"; | ||
| 5 | import { excerptRows, fileDiffRows, rowsToPatch } from "@t3tools/client-runtime/review-guide-tour"; | ||
| 6 | import { AppText as Text } from "../../../components/AppText"; | ||
| 7 | import { SymbolView } from "../../../components/AppSymbol"; | ||
| 8 | import { resolveNativeReviewDiffView } from "../../diffs/nativeReviewDiffSurface"; | ||
| 9 | import { buildNativeReviewDiffData } from "../../review/nativeReviewDiffAdapter"; | ||
| 10 | import { buildReviewParsedDiff } from "../../review/reviewModel"; | ||
| 11 | import { useNativeReviewDiffBridge } from "../../review/useNativeReviewDiffBridge"; | ||
| 12 | import { useAppearanceCodeSurface } from "../../settings/appearance/useAppearanceCodeSurface"; | ||
| 13 | import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; | ||
| 14 | import { PullRequestMarkdown } from "../PullRequestMarkdown"; | ||
| 15 | |||
| 16 | const EMPTY_IDS: readonly string[] = []; | ||
| 17 | |||
| 18 | export function ReviewGuideExcerpt({ | ||
| 19 | excerpt, | ||
| 20 | file, | ||
| 21 | loading, | ||
| 22 | onOpen, | ||
| 23 | }: { | ||
| 24 | readonly excerpt: PullRequestReviewGuideExcerpt; | ||
| 25 | readonly file: FileDiffMetadata | undefined; | ||
| 26 | readonly loading: boolean; | ||
| 27 | readonly onOpen: () => void; | ||
| 28 | }) { | ||
| 29 | const { nativeReviewDiffStyle, codeWordBreak } = useAppearanceCodeSurface(); | ||
| 30 | const { themeAppearance } = useAppearancePreferences(); | ||
| 31 | const slice = useMemo(() => { | ||
| 32 | try { | ||
| 33 | if (!file) return ""; | ||
| 34 | return rowsToPatch( | ||
| 35 | excerpt.path, | ||
| 36 | file.prevName ?? excerpt.path, | ||
| 37 | excerptRows(fileDiffRows(file), excerpt), | ||
| 38 | ); | ||
| 39 | } catch { | ||
| 40 | return ""; | ||
| 41 | } | ||
| 42 | }, [file, excerpt]); | ||
| 43 | const data = useMemo(() => { | ||
| 44 | const parsed = buildNativeReviewDiffData( | ||
| 45 | buildReviewParsedDiff(slice, `guide:${excerpt.path}:${excerpt.start}`), | ||
| 46 | ); | ||
| 47 | // The compact header and real line numbers provide context without file/hunk chrome. | ||
| 48 | return { | ||
| 49 | ...parsed, | ||
| 50 | rows: parsed.rows.filter((row) => row.kind !== "file" && row.kind !== "hunk"), | ||
| 51 | }; | ||
| 52 | }, [slice, excerpt.path, excerpt.start]); | ||
| 53 | const bridge = useNativeReviewDiffBridge({ | ||
| 54 | threadKey: null, | ||
| 55 | sectionId: `guide:${excerpt.path}:${excerpt.start}`, | ||
| 56 | diff: slice, | ||
| 57 | data, | ||
| 58 | collapsedFileIds: EMPTY_IDS, | ||
| 59 | viewedFileIds: EMPTY_IDS, | ||
| 60 | selectedRowIds: EMPTY_IDS, | ||
| 61 | canHighlight: true, | ||
| 62 | }); | ||
| 63 | const [measuredHeight, setMeasuredHeight] = useState<{ key: string; height: number } | null>( | ||
| 64 | null, | ||
| 65 | ); | ||
| 66 | const measurementKey = `${slice}:${codeWordBreak}:${nativeReviewDiffStyle.rowHeight}`; | ||
| 67 | const [NativeDiff] = useState(() => resolveNativeReviewDiffView()); | ||
| 68 | return ( | ||
| 69 | <View className="gap-3"> | ||
| 70 | {excerpt.caption.trim() ? <PullRequestMarkdown markdown={excerpt.caption} /> : null} | ||
| 71 | <View className="overflow-hidden rounded-lg border border-border-subtle"> | ||
| 72 | <Pressable | ||
| 73 | accessibilityRole="button" | ||
| 74 | accessibilityLabel={`Open ${excerpt.path} at line ${excerpt.start} in full diff`} | ||
| 75 | onPress={onOpen} | ||
| 76 | className="min-h-11 flex-row items-center gap-3 border-b border-border-subtle px-3 py-2 active:opacity-60" | ||
| 77 | > | ||
| 78 | <Text | ||
| 79 | className="min-w-0 flex-1 text-xs text-foreground-muted" | ||
| 80 | numberOfLines={1} | ||
| 81 | ellipsizeMode="middle" | ||
| 82 | > | ||
| 83 | {excerpt.path} | ||
| 84 | </Text> | ||
| 85 | <Text className="text-2xs text-foreground-muted"> | ||
| 86 | {excerpt.start}–{excerpt.end} | ||
| 87 | </Text> | ||
| 88 | <SymbolView name="arrow.up.right" size={13} tintColorClassName="accent-icon-muted" /> | ||
| 89 | </Pressable> | ||
| 90 | {NativeDiff && data.rows.length ? ( | ||
| 91 | <View | ||
| 92 | style={{ | ||
| 93 | height: | ||
| 94 | measuredHeight?.key === measurementKey | ||
| 95 | ? measuredHeight.height | ||
| 96 | : data.rows.length * nativeReviewDiffStyle.rowHeight, | ||
| 97 | }} | ||
| 98 | > | ||
| 99 | <NativeDiff | ||
| 100 | scrollEnabled={false} | ||
| 101 | bounces={false} | ||
| 102 | appearanceScheme={themeAppearance} | ||
| 103 | rowHeight={nativeReviewDiffStyle.rowHeight} | ||
| 104 | contentWidth={2800} | ||
| 105 | style={{ flex: 1 }} | ||
| 106 | rowsJson={bridge.rowsJson} | ||
| 107 | themeJson={bridge.themeJson} | ||
| 108 | styleJson={bridge.styleJson} | ||
| 109 | tokensPatchJson={bridge.tokensPatchJson} | ||
| 110 | tokensResetKey={bridge.tokensResetKey} | ||
| 111 | onDebug={(event) => { | ||
| 112 | bridge.onDebug(event); | ||
| 113 | const { message, contentHeight } = event.nativeEvent; | ||
| 114 | if (message === "metrics" && typeof contentHeight === "number") { | ||
| 115 | setMeasuredHeight((current) => | ||
| 116 | current?.key === measurementKey && current.height === contentHeight | ||
| 117 | ? current | ||
| 118 | : { key: measurementKey, height: contentHeight }, | ||
| 119 | ); | ||
| 120 | } | ||
| 121 | }} | ||
| 122 | onPressLine={onOpen} | ||
| 123 | /> | ||
| 124 | </View> | ||
| 125 | ) : ( | ||
| 126 | <Text className="p-3 text-xs text-foreground-muted"> | ||
| 127 | {loading | ||
| 128 | ? `Loading ${excerpt.path}…` | ||
| 129 | : `${excerpt.path}:${excerpt.start}–${excerpt.end} is not in the loaded diff.`} | ||
| 130 | </Text> | ||
| 131 | )} | ||
| 132 | </View> | ||
| 133 | </View> | ||
| 134 | ); | ||
| 135 | } | ||
apps/mobile/src/features/pull-requests/guide/guideDiffFocus.test.ts created+23| ... | @@ -0,0 +1,23 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { guideFocusedRows } from "./guideDiffFocus"; | ||
| 3 | |||
| 4 | describe("guide diff focus", () => { | ||
| 5 | const rows = [ | ||
| 6 | { id: "old", kind: "line" as const, fileId: "a", oldLineNumber: 10, change: "delete" as const }, | ||
| 7 | { id: "new", kind: "line" as const, fileId: "a", newLineNumber: 20, change: "add" as const }, | ||
| 8 | { id: "context", kind: "line" as const, fileId: "a", oldLineNumber: 11, newLineNumber: 21 }, | ||
| 9 | { id: "other", kind: "line" as const, fileId: "b", newLineNumber: 20 }, | ||
| 10 | ]; | ||
| 11 | it("uses the requested side and includes both range boundaries only in the target file", () => { | ||
| 12 | expect( | ||
| 13 | guideFocusedRows(rows, "a", { side: "old", start: 10, end: 11 }).map((r) => r.id), | ||
| 14 | ).toEqual(["old", "context"]); | ||
| 15 | expect( | ||
| 16 | guideFocusedRows(rows, "a", { side: "new", start: 20, end: 21 }).map((r) => r.id), | ||
| 17 | ).toEqual(["new", "context"]); | ||
| 18 | }); | ||
| 19 | it("does not select unrelated rows while a later file is loading", () => { | ||
| 20 | expect(guideFocusedRows(rows, undefined, { side: "new", start: 20, end: 21 })).toEqual([]); | ||
| 21 | expect(guideFocusedRows(rows, "a", undefined)).toEqual([]); | ||
| 22 | }); | ||
| 23 | }); | ||
apps/mobile/src/features/pull-requests/guide/guideDiffFocus.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import type { NativeReviewDiffRow } from "../../diffs/nativeReviewDiffSurface"; | ||
| 2 | |||
| 3 | export type GuideDiffFocus = { | ||
| 4 | readonly side: "old" | "new"; | ||
| 5 | readonly start: number; | ||
| 6 | readonly end: number; | ||
| 7 | }; | ||
| 8 | |||
| 9 | /** Match original diff coordinates, including deletions and renamed files. */ | ||
| 10 | export function guideFocusedRows( | ||
| 11 | rows: readonly NativeReviewDiffRow[], | ||
| 12 | fileId: string | undefined, | ||
| 13 | focus: GuideDiffFocus | undefined, | ||
| 14 | ) { | ||
| 15 | if (!fileId || !focus) return []; | ||
| 16 | return rows.filter((row) => { | ||
| 17 | const line = focus.side === "old" ? row.oldLineNumber : row.newLineNumber; | ||
| 18 | return ( | ||
| 19 | row.fileId === fileId && | ||
| 20 | row.kind === "line" && | ||
| 21 | line != null && | ||
| 22 | line >= focus.start && | ||
| 23 | line <= focus.end | ||
| 24 | ); | ||
| 25 | }); | ||
| 26 | } | ||
apps/mobile/src/features/pull-requests/guide/reviewGuideState.test.ts created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { | ||
| 3 | IsoDateTime, | ||
| 4 | ProviderInstanceId, | ||
| 5 | type PullRequestReviewGuideResult, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { reviewGuideState } from "./reviewGuideState"; | ||
| 8 | |||
| 9 | const guide: NonNullable<PullRequestReviewGuideResult["guide"]> = { | ||
| 10 | headSha: "old-head", | ||
| 11 | generatedAt: IsoDateTime.make("2026-10-11T00:00:00.000Z"), | ||
| 12 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | ||
| 13 | stops: [], | ||
| 14 | }; | ||
| 15 | |||
| 16 | describe("mobile review guide state", () => { | ||
| 17 | it("keeps a previous guide readable during a rewrite", () => { | ||
| 18 | const state = reviewGuideState({ | ||
| 19 | guide, | ||
| 20 | currentHeadSha: "new-head", | ||
| 21 | run: { _tag: "Running", headSha: "new-head", startedAt: guide.generatedAt }, | ||
| 22 | }); | ||
| 23 | expect(state).toMatchObject({ guide, stale: true, running: true, failure: null }); | ||
| 24 | }); | ||
| 25 | it("does not label an unknown head stale", () => { | ||
| 26 | expect(reviewGuideState({ guide, currentHeadSha: null, run: null }).stale).toBe(false); | ||
| 27 | }); | ||
| 28 | it("shows only failures for the current head", () => { | ||
| 29 | const run = { | ||
| 30 | _tag: "Failed" as const, | ||
| 31 | headSha: "old-head", | ||
| 32 | failedAt: guide.generatedAt, | ||
| 33 | detail: "The writer disconnected.", | ||
| 34 | }; | ||
| 35 | expect(reviewGuideState({ guide: null, currentHeadSha: "old-head", run }).failure).toBe( | ||
| 36 | run.detail, | ||
| 37 | ); | ||
| 38 | expect(reviewGuideState({ guide: null, currentHeadSha: "new-head", run }).failure).toBeNull(); | ||
| 39 | }); | ||
| 40 | it("starts empty without claiming a job is running", () => { | ||
| 41 | expect(reviewGuideState(null)).toEqual({ | ||
| 42 | guide: null, | ||
| 43 | stale: false, | ||
| 44 | running: false, | ||
| 45 | failure: null, | ||
| 46 | }); | ||
| 47 | }); | ||
| 48 | }); | ||
apps/mobile/src/features/pull-requests/guide/reviewGuideState.ts created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | import type { PullRequestReviewGuideResult } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | /** Old failures must not follow a PR onto a different head. */ | ||
| 4 | export function reviewGuideState(data: PullRequestReviewGuideResult | null) { | ||
| 5 | const guide = data?.guide ?? null; | ||
| 6 | return { | ||
| 7 | guide, | ||
| 8 | running: data?.run?._tag === "Running", | ||
| 9 | stale: guide !== null && data?.currentHeadSha != null && guide.headSha !== data.currentHeadSha, | ||
| 10 | failure: | ||
| 11 | data?.run?._tag === "Failed" && data.run.headSha === data.currentHeadSha | ||
| 12 | ? data.run.detail | ||
| 13 | : null, | ||
| 14 | }; | ||
| 15 | } | ||
apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts created+61| ... | @@ -0,0 +1,61 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useFocusEffect } from "@react-navigation/native"; | ||
| 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 4 | import { useCallback, useRef, useState } from "react"; | ||
| 5 | import { composerPullRequests } from "../../../state/pull-requests"; | ||
| 6 | import { useEnvironmentQuery } from "../../../state/query"; | ||
| 7 | import { serverEnvironment } from "../../../state/server"; | ||
| 8 | import { useAtomCommand } from "../../../state/use-atom-command"; | ||
| 9 | import { pullRequestTarget, type PullRequestParams } from "../usePullRequestDiff"; | ||
| 10 | |||
| 11 | export function useReviewGuidesEnabled(params: PullRequestParams) { | ||
| 12 | const settings = useAtomValue( | ||
| 13 | serverEnvironment.settingsValueAtom(pullRequestTarget(params).environmentId), | ||
| 14 | ); | ||
| 15 | return settings?.reviewGuidesEnabled === true; | ||
| 16 | } | ||
| 17 | |||
| 18 | export function useReviewGuide(params: PullRequestParams) { | ||
| 19 | const target = pullRequestTarget(params); | ||
| 20 | const enabled = useReviewGuidesEnabled(params); | ||
| 21 | const query = useEnvironmentQuery(enabled ? composerPullRequests.reviewGuide(target) : null); | ||
| 22 | const canWrite = useAtomValue( | ||
| 23 | composerPullRequests.generateReviewGuide.permissionAtom(target.environmentId), | ||
| 24 | ); | ||
| 25 | const generate = useAtomCommand(composerPullRequests.generateReviewGuide, { | ||
| 26 | reportFailure: false, | ||
| 27 | }); | ||
| 28 | const [starting, setStarting] = useState(false); | ||
| 29 | const [error, setError] = useState<string | null>(null); | ||
| 30 | const pending = useRef(false); | ||
| 31 | const running = starting || query.data?.run?._tag === "Running"; | ||
| 32 | const refresh = query.refresh; | ||
| 33 | useFocusEffect( | ||
| 34 | useCallback(() => { | ||
| 35 | if (!enabled) return; | ||
| 36 | refresh(); | ||
| 37 | if (!running) return; | ||
| 38 | const timer = setInterval(refresh, 5_000); | ||
| 39 | return () => clearInterval(timer); | ||
| 40 | }, [enabled, refresh, running]), | ||
| 41 | ); | ||
| 42 | const write = async () => { | ||
| 43 | if (!enabled || !canWrite || running || pending.current) return; | ||
| 44 | pending.current = true; | ||
| 45 | setStarting(true); | ||
| 46 | setError(null); | ||
| 47 | try { | ||
| 48 | const result = await generate(target); | ||
| 49 | if (result._tag === "Failure") { | ||
| 50 | const cause = squashAtomCommandFailure(result); | ||
| 51 | setError(cause instanceof Error ? cause.message : "Could not start the review guide."); | ||
| 52 | } else { | ||
| 53 | refresh(); | ||
| 54 | } | ||
| 55 | } finally { | ||
| 56 | pending.current = false; | ||
| 57 | setStarting(false); | ||
| 58 | } | ||
| 59 | }; | ||
| 60 | return { ...query, enabled, canWrite, running, write, startError: error }; | ||
| 61 | } | ||
apps/mobile/src/features/pull-requests/pullRequestActivityFilter.test.ts created+90| ... | @@ -0,0 +1,90 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { IsoDateTime, type PullRequestActivity, type PullRequestActor } from "@t3tools/contracts"; | ||
| 3 | import { DEFAULT_ACTIVITY_FILTER, filterPullRequestActivity } from "./pullRequestActivityFilter"; | ||
| 4 | |||
| 5 | const date = IsoDateTime.make("2026-10-11T00:00:00.000Z"); | ||
| 6 | const human: PullRequestActor = { login: "reader", name: null, avatarUrl: null }; | ||
| 7 | const bot: PullRequestActor = { ...human, login: "reviewer[bot]" }; | ||
| 8 | const comment = (id: string, author = human, reviewState: string | null = null) => ({ | ||
| 9 | id, | ||
| 10 | author, | ||
| 11 | reviewState, | ||
| 12 | kind: "issue-comment" as const, | ||
| 13 | body: "A comment", | ||
| 14 | createdAt: date, | ||
| 15 | url: null, | ||
| 16 | path: null, | ||
| 17 | }); | ||
| 18 | const activity: PullRequestActivity = { | ||
| 19 | comments: [ | ||
| 20 | comment("human"), | ||
| 21 | comment("bot", bot), | ||
| 22 | comment("dismissed", human, "DISMISSED"), | ||
| 23 | comment("reply"), | ||
| 24 | ], | ||
| 25 | commentCount: 4, | ||
| 26 | commentsTruncated: false, | ||
| 27 | commits: [{ oid: "abcdef", messageHeadline: "A change", committedDate: date }], | ||
| 28 | reviewThreads: [ | ||
| 29 | { | ||
| 30 | id: "thread", | ||
| 31 | path: "file.ts", | ||
| 32 | line: 1, | ||
| 33 | side: "right", | ||
| 34 | isResolved: true, | ||
| 35 | isOutdated: false, | ||
| 36 | comments: [comment("reply")], | ||
| 37 | }, | ||
| 38 | ], | ||
| 39 | }; | ||
| 40 | |||
| 41 | describe("mobile activity filters", () => { | ||
| 42 | it("hides bots and finished conversations by default, counting each thread once", () => { | ||
| 43 | const result = filterPullRequestActivity(activity, DEFAULT_ACTIVITY_FILTER); | ||
| 44 | expect(result.activity.comments.map((item) => item.id)).toEqual(["human"]); | ||
| 45 | expect(result.activity.reviewThreads).toEqual([]); | ||
| 46 | expect(result.activity.commits).toHaveLength(1); | ||
| 47 | expect(result.hiddenCount).toBe(3); | ||
| 48 | }); | ||
| 49 | it("restores bot comments and finished conversations without duplicate thread replies", () => { | ||
| 50 | const result = filterPullRequestActivity(activity, { | ||
| 51 | ...DEFAULT_ACTIVITY_FILTER, | ||
| 52 | bots: true, | ||
| 53 | resolved: true, | ||
| 54 | }); | ||
| 55 | expect(result.activity.comments.map((item) => item.id)).toEqual(["human", "bot", "dismissed"]); | ||
| 56 | expect(result.activity.reviewThreads).toHaveLength(1); | ||
| 57 | expect(result.hiddenCount).toBe(0); | ||
| 58 | }); | ||
| 59 | it("keeps partially loaded bot discussions visible", () => { | ||
| 60 | const thread = { | ||
| 61 | ...activity.reviewThreads[0]!, | ||
| 62 | isResolved: false, | ||
| 63 | comments: [comment("bot-reply", bot)], | ||
| 64 | }; | ||
| 65 | for (const incomplete of [{ commentCount: 2 }, { nextCommentsCursor: "next" }]) { | ||
| 66 | const result = filterPullRequestActivity( | ||
| 67 | { ...activity, reviewThreads: [{ ...thread, ...incomplete }] }, | ||
| 68 | DEFAULT_ACTIVITY_FILTER, | ||
| 69 | ); | ||
| 70 | expect(result.activity.reviewThreads).toHaveLength(1); | ||
| 71 | } | ||
| 72 | expect( | ||
| 73 | filterPullRequestActivity({ ...activity, reviewThreads: [thread] }, DEFAULT_ACTIVITY_FILTER) | ||
| 74 | .activity.reviewThreads, | ||
| 75 | ).toEqual([]); | ||
| 76 | }); | ||
| 77 | it("provides a commits-only view and a comments-only view", () => { | ||
| 78 | const commits = filterPullRequestActivity(activity, { | ||
| 79 | ...DEFAULT_ACTIVITY_FILTER, | ||
| 80 | kind: "commits", | ||
| 81 | }).activity; | ||
| 82 | expect(commits.comments).toEqual([]); | ||
| 83 | expect(commits.reviewThreads).toEqual([]); | ||
| 84 | expect(commits.commits).toHaveLength(1); | ||
| 85 | expect( | ||
| 86 | filterPullRequestActivity(activity, { ...DEFAULT_ACTIVITY_FILTER, kind: "comments" }).activity | ||
| 87 | .commits, | ||
| 88 | ).toEqual([]); | ||
| 89 | }); | ||
| 90 | }); | ||
apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts created+66| ... | @@ -0,0 +1,66 @@ | ||
| 1 | import type { PullRequestActivity, PullRequestActor } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export interface ActivityFilter { | ||
| 4 | readonly kind: "all" | "comments" | "commits"; | ||
| 5 | readonly bots: boolean; | ||
| 6 | readonly resolved: boolean; | ||
| 7 | readonly newestFirst: boolean; | ||
| 8 | } | ||
| 9 | |||
| 10 | export const DEFAULT_ACTIVITY_FILTER: ActivityFilter = { | ||
| 11 | kind: "all", | ||
| 12 | bots: false, | ||
| 13 | resolved: false, | ||
| 14 | newestFirst: true, | ||
| 15 | }; | ||
| 16 | |||
| 17 | function isBot(actor: PullRequestActor | null) { | ||
| 18 | return actor?.isBot === true || actor?.login.endsWith("[bot]") === true; | ||
| 19 | } | ||
| 20 | |||
| 21 | function eventCount(activity: PullRequestActivity) { | ||
| 22 | const discussionIds = new Set( | ||
| 23 | activity.reviewThreads.flatMap((thread) => thread.comments.map((comment) => comment.id)), | ||
| 24 | ); | ||
| 25 | return ( | ||
| 26 | activity.comments.filter((comment) => !discussionIds.has(comment.id)).length + | ||
| 27 | activity.reviewThreads.length + | ||
| 28 | activity.commits.length | ||
| 29 | ); | ||
| 30 | } | ||
| 31 | |||
| 32 | export function filterPullRequestActivity(activity: PullRequestActivity, filter: ActivityFilter) { | ||
| 33 | // A discussion's replies stay together, even when a bot replies to a human. | ||
| 34 | const reviewThreads = | ||
| 35 | filter.kind === "commits" | ||
| 36 | ? [] | ||
| 37 | : activity.reviewThreads.filter( | ||
| 38 | (thread) => | ||
| 39 | (filter.resolved || !thread.isResolved) && | ||
| 40 | (filter.bots || | ||
| 41 | (thread.commentCount ?? 0) > thread.comments.length || | ||
| 42 | thread.nextCommentsCursor != null || | ||
| 43 | thread.comments.some((comment) => !isBot(comment.author))), | ||
| 44 | ); | ||
| 45 | const allDiscussionIds = new Set( | ||
| 46 | activity.reviewThreads.flatMap((thread) => thread.comments.map((comment) => comment.id)), | ||
| 47 | ); | ||
| 48 | const comments = | ||
| 49 | filter.kind === "commits" | ||
| 50 | ? [] | ||
| 51 | : activity.comments.filter( | ||
| 52 | (comment) => | ||
| 53 | !allDiscussionIds.has(comment.id) && | ||
| 54 | (filter.bots || !isBot(comment.author)) && | ||
| 55 | (filter.resolved || comment.reviewState?.trim().toLowerCase() !== "dismissed"), | ||
| 56 | ); | ||
| 57 | const filtered = { | ||
| 58 | ...activity, | ||
| 59 | comments, | ||
| 60 | reviewThreads, | ||
| 61 | commits: filter.kind === "comments" ? [] : activity.commits, | ||
| 62 | commentsTruncated: filter.kind !== "commits" && activity.commentsTruncated, | ||
| 63 | reviewThreadsTruncated: filter.kind !== "commits" && activity.reviewThreadsTruncated, | ||
| 64 | }; | ||
| 65 | return { activity: filtered, hiddenCount: eventCount(activity) - eventCount(filtered) }; | ||
| 66 | } | ||
apps/mobile/src/features/pull-requests/pullRequestAnimations.ts created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | import { | ||
| 2 | FadeIn, | ||
| 3 | FadeOut, | ||
| 4 | LinearTransition, | ||
| 5 | ReduceMotion, | ||
| 6 | withTiming, | ||
| 7 | type LayoutAnimationFunction, | ||
| 8 | } from "react-native-reanimated"; | ||
| 9 | |||
| 10 | // Match the model picker: move existing rows, briefly fade additions and removals. | ||
| 11 | export const PR_ROW_LAYOUT = LinearTransition.duration(180).reduceMotion(ReduceMotion.System); | ||
| 12 | export const PR_ROW_ENTER = FadeIn.duration(140).reduceMotion(ReduceMotion.System); | ||
| 13 | export const PR_ROW_EXIT = FadeOut.duration(100).reduceMotion(ReduceMotion.System); | ||
| 14 | |||
| 15 | export const PR_DISCLOSURE_ENTER = FadeIn.delay(80).duration(120).reduceMotion(ReduceMotion.System); | ||
| 16 | |||
| 17 | // Follow expanding status rows without interpolating the clipped markdown's height. | ||
| 18 | export const PR_POSITION_LAYOUT: LayoutAnimationFunction = (values) => { | ||
| 19 | "worklet"; | ||
| 20 | return { | ||
| 21 | initialValues: { | ||
| 22 | originX: values.targetOriginX, | ||
| 23 | originY: values.currentOriginY, | ||
| 24 | width: values.targetWidth, | ||
| 25 | height: values.targetHeight, | ||
| 26 | }, | ||
| 27 | animations: { | ||
| 28 | originX: values.targetOriginX, | ||
| 29 | originY: withTiming(values.targetOriginY, { | ||
| 30 | duration: 180, | ||
| 31 | reduceMotion: ReduceMotion.System, | ||
| 32 | }), | ||
| 33 | width: values.targetWidth, | ||
| 34 | height: values.targetHeight, | ||
| 35 | }, | ||
| 36 | }; | ||
| 37 | }; | ||
apps/mobile/src/features/pull-requests/pullRequestFileFilters.test.ts+11| ... | @@ -67,6 +67,17 @@ describe("mobile PR test file filter", () => { | ... | @@ -67,6 +67,17 @@ describe("mobile PR test file filter", () => { |
| 67 | expect(parsed.files).toHaveLength(4); | 67 | expect(parsed.files).toHaveLength(4); |
| 68 | }); | 68 | }); |
| 69 | 69 | ||
| 70 | it("keeps a guide's target test file visible without exposing other hidden tests", () => { | ||
| 71 | const parsed = buildReviewParsedDiff( | ||
| 72 | ["src/first.test.ts", "src/app.ts", "src/second.test.ts"].map(patch).join("\n"), | ||
| 73 | "pr", | ||
| 74 | ); | ||
| 75 | const visible = filterPullRequestTestFiles(parsed, true, "src/second.test.ts"); | ||
| 76 | if (parsed.kind !== "files" || visible.kind !== "files") throw new Error("Expected files"); | ||
| 77 | expect(visible.files.map((file) => file.path)).toEqual(["src/app.ts", "src/second.test.ts"]); | ||
| 78 | expect(visible.files[1]).toBe(parsed.files[2]); | ||
| 79 | }); | ||
| 80 | |||
| 70 | it("uses the current path when a file is renamed out of a test directory", () => { | 81 | it("uses the current path when a file is renamed out of a test directory", () => { |
| 71 | const parsed = buildReviewParsedDiff( | 82 | const parsed = buildReviewParsedDiff( |
| 72 | "diff --git a/tests/helper.ts b/src/helper.ts\nsimilarity index 100%\nrename from tests/helper.ts\nrename to src/helper.ts\n", | 83 | "diff --git a/tests/helper.ts b/src/helper.ts\nsimilarity index 100%\nrename from tests/helper.ts\nrename to src/helper.ts\n", |
apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts+4-1| ... | @@ -21,9 +21,12 @@ export function isPullRequestTestFile(path: string): boolean { | ... | @@ -21,9 +21,12 @@ export function isPullRequestTestFile(path: string): boolean { |
| 21 | export function filterPullRequestTestFiles( | 21 | export function filterPullRequestTestFiles( |
| 22 | parsed: ReviewParsedDiff, | 22 | parsed: ReviewParsedDiff, |
| 23 | hideTestFiles: boolean, | 23 | hideTestFiles: boolean, |
| 24 | includePath?: string, | ||
| 24 | ): ReviewParsedDiff { | 25 | ): ReviewParsedDiff { |
| 25 | if (!hideTestFiles || parsed.kind !== "files") return parsed; | 26 | if (!hideTestFiles || parsed.kind !== "files") return parsed; |
| 26 | const files = parsed.files.filter((file) => !isPullRequestTestFile(file.path)); | 27 | const files = parsed.files.filter( |
| 28 | (file) => file.path === includePath || !isPullRequestTestFile(file.path), | ||
| 29 | ); | ||
| 27 | if (files.length === parsed.files.length) return parsed; | 30 | if (files.length === parsed.files.length) return parsed; |
| 28 | return { | 31 | return { |
| 29 | ...parsed, | 32 | ...parsed, |
apps/mobile/src/features/pull-requests/pullRequestTabScroll.test.ts created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { pullRequestTabOffset } from "./pullRequestTabScroll"; | ||
| 3 | |||
| 4 | describe("pull request tab scroll", () => { | ||
| 5 | it("keeps the header fixed while switching, even if the destination was scrolled deeply", () => { | ||
| 6 | expect(pullRequestTabOffset(-100, 900, 200)).toBe(-100); | ||
| 7 | expect(pullRequestTabOffset(75, 900, 200)).toBe(75); | ||
| 8 | }); | ||
| 9 | it("resumes the destination body only when the shared header is already collapsed", () => { | ||
| 10 | expect(pullRequestTabOffset(600, 900, 200)).toBe(900); | ||
| 11 | expect(pullRequestTabOffset(600, 50, 200)).toBe(200); | ||
| 12 | expect(pullRequestTabOffset(600, undefined, 200)).toBe(200); | ||
| 13 | }); | ||
| 14 | }); | ||
apps/mobile/src/features/pull-requests/pullRequestTabScroll.ts created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | /** Preserve the shared header's position, then each tab's own reading position. */ | ||
| 2 | export function pullRequestTabOffset( | ||
| 3 | current: number, | ||
| 4 | saved: number | undefined, | ||
| 5 | headerEnd: number, | ||
| 6 | ) { | ||
| 7 | return current < headerEnd ? current : Math.max(headerEnd, saved ?? headerEnd); | ||
| 8 | } | ||
apps/mobile/src/features/pull-requests/usePullRequestFileFilter.ts+3-3| ... | @@ -5,14 +5,14 @@ import type { ReviewParsedDiff } from "../review/reviewModel"; | ... | @@ -5,14 +5,14 @@ import type { ReviewParsedDiff } from "../review/reviewModel"; |
| 5 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 5 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 6 | import { filterPullRequestTestFiles } from "./pullRequestFileFilters"; | 6 | import { filterPullRequestTestFiles } from "./pullRequestFileFilters"; |
| 7 | 7 | ||
| 8 | export function usePullRequestFileFilter(parsed: ReviewParsedDiff) { | 8 | export function usePullRequestFileFilter(parsed: ReviewParsedDiff, includePath?: string) { |
| 9 | const preferences = useAtomValue(mobilePreferencesAtom); | 9 | const preferences = useAtomValue(mobilePreferencesAtom); |
| 10 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | 10 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 11 | const hideTestFiles = | 11 | const hideTestFiles = |
| 12 | AsyncResult.isSuccess(preferences) && preferences.value.pullRequestHideTestFiles === true; | 12 | AsyncResult.isSuccess(preferences) && preferences.value.pullRequestHideTestFiles === true; |
| 13 | const visible = useMemo( | 13 | const visible = useMemo( |
| 14 | () => filterPullRequestTestFiles(parsed, hideTestFiles), | 14 | () => filterPullRequestTestFiles(parsed, hideTestFiles, includePath), |
| 15 | [parsed, hideTestFiles], | 15 | [parsed, hideTestFiles, includePath], |
| 16 | ); | 16 | ); |
| 17 | const hiddenCount = | 17 | const hiddenCount = |
| 18 | parsed.kind === "files" && visible.kind === "files" ? parsed.fileCount - visible.fileCount : 0; | 18 | parsed.kind === "files" && visible.kind === "files" ? parsed.fileCount - visible.fileCount : 0; |
apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx+7-2| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; | 1 | import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; |
| 2 | import { useCallback, useMemo, useState, type ReactNode } from "react"; | 2 | import { useCallback, useMemo, useState, type ReactNode } from "react"; |
| 3 | import { Keyboard, Platform, View, type NativeSyntheticEvent } from "react-native"; | 3 | import { Keyboard, Platform, View, type NativeSyntheticEvent } from "react-native"; |
| 4 | import { Tabs, type TabSelectedEvent } from "react-native-screens"; | 4 | import { Tabs, type TabSelectedEvent, type TabSelectionRejectedEvent } from "react-native-screens"; |
| 5 | 5 | ||
| 6 | import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; | 6 | import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; |
| 7 | import type { WorkspaceTab } from "./workspace-tabs-state"; | 7 | import type { WorkspaceTab } from "./workspace-tabs-state"; |
| ... | @@ -70,11 +70,16 @@ export function WorkspaceNativeTabs(props: { | ... | @@ -70,11 +70,16 @@ export function WorkspaceNativeTabs(props: { |
| 70 | tabBarMinimizeBehavior: "never", | 70 | tabBarMinimizeBehavior: "never", |
| 71 | tabBarLeadingWidth: phoneBar ? 220 : 0, | 71 | tabBarLeadingWidth: phoneBar ? 220 : 0, |
| 72 | }} | 72 | }} |
| 73 | onTabSelectionRejected={({ | ||
| 74 | nativeEvent, | ||
| 75 | }: NativeSyntheticEvent<TabSelectionRejectedEvent>) => { | ||
| 76 | setProvenance(nativeEvent.provenance); | ||
| 77 | }} | ||
| 73 | onTabSelected={({ nativeEvent }: NativeSyntheticEvent<TabSelectedEvent>) => { | 78 | onTabSelected={({ nativeEvent }: NativeSyntheticEvent<TabSelectedEvent>) => { |
| 74 | const tab = nativeEvent.selectedScreenKey; | 79 | const tab = nativeEvent.selectedScreenKey; |
| 75 | if (tab !== "threads" && tab !== "pullRequests") return; | 80 | if (tab !== "threads" && tab !== "pullRequests") return; |
| 76 | setProvenance(nativeEvent.provenance); | 81 | setProvenance(nativeEvent.provenance); |
| 77 | if (tab === selectedTab) return; | 82 | if (nativeEvent.actionOrigin === "programmatic-js" || tab === selectedTab) return; |
| 78 | Keyboard.dismiss(); | 83 | Keyboard.dismiss(); |
| 79 | props.onSelect(); | 84 | props.onSelect(); |
| 80 | workspace.dispatch({ type: "select", tab }); | 85 | workspace.dispatch({ type: "select", tab }); |
apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx+14-5| ... | @@ -2,7 +2,7 @@ import { useNavigation } from "@react-navigation/native"; | ... | @@ -2,7 +2,7 @@ import { useNavigation } from "@react-navigation/native"; |
| 2 | import { use, useCallback, useMemo } from "react"; | 2 | import { use, useCallback, useMemo } from "react"; |
| 3 | import { Platform, View, useWindowDimensions } from "react-native"; | 3 | import { Platform, View, useWindowDimensions } from "react-native"; |
| 4 | import { useKeyboardState } from "react-native-keyboard-controller"; | 4 | import { useKeyboardState } from "react-native-keyboard-controller"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 5 | import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | 6 | ||
| 7 | import { CompactBrandTitle } from "../../components/CompactBrandTitle"; | 7 | import { CompactBrandTitle } from "../../components/CompactBrandTitle"; |
| 8 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 8 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| ... | @@ -24,7 +24,8 @@ import { | ... | @@ -24,7 +24,8 @@ import { |
| 24 | } from "./WorkspaceTabsContext"; | 24 | } from "./WorkspaceTabsContext"; |
| 25 | import { WorkspaceToolbar } from "./WorkspaceToolbar"; | 25 | import { WorkspaceToolbar } from "./WorkspaceToolbar"; |
| 26 | import { WORKSPACE_DOCK, workspaceToolbarHeight } from "./workspace-tabs-state"; | 26 | import { WORKSPACE_DOCK, workspaceToolbarHeight } from "./workspace-tabs-state"; |
| 27 | import { useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar"; | 27 | import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs"; |
| 28 | import { useSidebarWorkspaceTabs, useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar"; | ||
| 28 | 29 | ||
| 29 | const TAB_TITLES = { | 30 | const TAB_TITLES = { |
| 30 | threads: "Threads", | 31 | threads: "Threads", |
| ... | @@ -58,6 +59,7 @@ function WorkspaceRootContent() { | ... | @@ -58,6 +59,7 @@ function WorkspaceRootContent() { |
| 58 | const columnMetrics = useNativeColumnLayoutMetrics(); | 59 | const columnMetrics = useNativeColumnLayoutMetrics(); |
| 59 | const nativeMetrics = useNativeLayoutMetrics(); | 60 | const nativeMetrics = useNativeLayoutMetrics(); |
| 60 | const systemTabBar = useSystemWorkspaceTabBar(); | 61 | const systemTabBar = useSystemWorkspaceTabBar(); |
| 62 | const sidebarTabs = useSidebarWorkspaceTabs(); | ||
| 61 | const sidebarNavigation = | 63 | const sidebarNavigation = |
| 62 | primaryColumn !== null && | 64 | primaryColumn !== null && |
| 63 | (layout.usesSplitView || | 65 | (layout.usesSplitView || |
| ... | @@ -86,7 +88,7 @@ function WorkspaceRootContent() { | ... | @@ -86,7 +88,7 @@ function WorkspaceRootContent() { |
| 86 | const bottomClearance = | 88 | const bottomClearance = |
| 87 | Platform.OS === "ios" | 89 | Platform.OS === "ios" |
| 88 | ? Math.max(insets.bottom, keyboardHeight) + | 90 | ? Math.max(insets.bottom, keyboardHeight) + |
| 89 | (systemTabBar || tab.searchMode !== "resting" | 91 | (systemTabBar || sidebarTabs || tab.searchMode !== "resting" |
| 90 | ? 16 | 92 | ? 16 |
| 91 | : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16) | 93 | : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16) |
| 92 | : keyboardHeight > 0 && tab.searchMode !== "resting" | 94 | : keyboardHeight > 0 && tab.searchMode !== "resting" |
| ... | @@ -96,7 +98,10 @@ function WorkspaceRootContent() { | ... | @@ -96,7 +98,10 @@ function WorkspaceRootContent() { |
| 96 | 98 | ||
| 97 | return ( | 99 | return ( |
| 98 | <WorkspaceTabsContext value={context}> | 100 | <WorkspaceTabsContext value={context}> |
| 99 | <View className="flex-1 bg-screen"> | 101 | <View |
| 102 | className="flex-1 bg-screen" | ||
| 103 | style={sidebarTabs ? { paddingTop: insets.top } : undefined} | ||
| 104 | > | ||
| 100 | {Platform.OS === "ios" ? ( | 105 | {Platform.OS === "ios" ? ( |
| 101 | <NativeStackScreenOptions | 106 | <NativeStackScreenOptions |
| 102 | optionsVersion={[state.selectedTab, state.selectionRevision, width, sidebarNavigation]} | 107 | optionsVersion={[state.selectedTab, state.selectionRevision, width, sidebarNavigation]} |
| ... | @@ -132,7 +137,11 @@ function WorkspaceRootContent() { | ... | @@ -132,7 +137,11 @@ function WorkspaceRootContent() { |
| 132 | ]} | 137 | ]} |
| 133 | /> | 138 | /> |
| 134 | )} | 139 | )} |
| 135 | {state.selectedTab === "threads" ? <HomeRouteScreen /> : <PullRequestsScreen />} | 140 | {sidebarTabs ? <WorkspaceSidebarTabs /> : null} |
| 141 | {/* The pinned picker already clears the navigation bar; lists must not inset it again. */} | ||
| 142 | <SafeAreaInsetsContext value={sidebarTabs ? { ...insets, top: 0 } : insets}> | ||
| 143 | {state.selectedTab === "threads" ? <HomeRouteScreen /> : <PullRequestsScreen />} | ||
| 144 | </SafeAreaInsetsContext> | ||
| 136 | <WorkspaceToolbar | 145 | <WorkspaceToolbar |
| 137 | sidebar={sidebarNavigation} | 146 | sidebar={sidebarNavigation} |
| 138 | onNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | 147 | onNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
apps/mobile/src/features/workspace/WorkspaceSidebarTabs.ios.tsx created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | import { Host, Picker, Text } from "@expo/ui/swift-ui"; | ||
| 2 | import { pickerStyle, tag } from "@expo/ui/swift-ui/modifiers"; | ||
| 3 | import { StackActions, useNavigation } from "@react-navigation/native"; | ||
| 4 | import { Keyboard, View } from "react-native"; | ||
| 5 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 6 | import { useWorkspaceTabs } from "./WorkspaceTabsContext"; | ||
| 7 | import type { WorkspaceTab } from "./workspace-tabs-state"; | ||
| 8 | |||
| 9 | /** Wide iPad navigation belongs to the sidebar, leaving the detail's header unobstructed. */ | ||
| 10 | export function WorkspaceSidebarTabs() { | ||
| 11 | const workspace = useWorkspaceTabs()!; | ||
| 12 | const navigation = useNavigation(); | ||
| 13 | const { themeAppearance } = useAppearancePreferences(); | ||
| 14 | return ( | ||
| 15 | <View className="px-4 pb-3 pt-2"> | ||
| 16 | <Host | ||
| 17 | matchContents={{ vertical: true }} | ||
| 18 | colorScheme={themeAppearance} | ||
| 19 | style={{ width: "100%" }} | ||
| 20 | > | ||
| 21 | <Picker | ||
| 22 | label="Workspace" | ||
| 23 | selection={workspace.state.selectedTab} | ||
| 24 | onSelectionChange={(tab: WorkspaceTab) => { | ||
| 25 | if (tab === workspace.state.selectedTab) return; | ||
| 26 | Keyboard.dismiss(); | ||
| 27 | if (navigation.getState()?.index) navigation.dispatch(StackActions.popToTop()); | ||
| 28 | workspace.dispatch({ type: "select", tab }); | ||
| 29 | }} | ||
| 30 | modifiers={[pickerStyle("segmented")]} | ||
| 31 | > | ||
| 32 | <Text modifiers={[tag("threads")]}>Threads</Text> | ||
| 33 | <Text modifiers={[tag("pullRequests")]}>Pull requests</Text> | ||
| 34 | </Picker> | ||
| 35 | </Host> | ||
| 36 | </View> | ||
| 37 | ); | ||
| 38 | } | ||
apps/mobile/src/features/workspace/WorkspaceSidebarTabs.tsx created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | /** The sidebar workspace picker is native to wide iPad layouts. */ | ||
| 2 | export function WorkspaceSidebarTabs() { | ||
| 3 | return null; | ||
| 4 | } | ||
apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx+2-5| ... | @@ -9,20 +9,17 @@ import { NativeWorkspaceTabActivityContext } from "../../native/v5-workspace-con | ... | @@ -9,20 +9,17 @@ import { NativeWorkspaceTabActivityContext } from "../../native/v5-workspace-con |
| 9 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | 9 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 10 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 10 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 11 | import { useWorkspaceTabs } from "./WorkspaceTabsContext"; | 11 | import { useWorkspaceTabs } from "./WorkspaceTabsContext"; |
| 12 | import { useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar"; | ||
| 13 | 12 | ||
| 14 | /** Root actions stay in the header; UIKit owns the tab bar and bottom compose button. */ | 13 | /** Root actions stay in the header; UIKit owns the bottom compose button. */ |
| 15 | export function WorkspaceToolbar(props: { | 14 | export function WorkspaceToolbar(props: { |
| 16 | readonly onNewTask: () => void; | 15 | readonly onNewTask: () => void; |
| 17 | readonly sidebar?: boolean; | 16 | readonly sidebar?: boolean; |
| 18 | }) { | 17 | }) { |
| 19 | const workspace = useWorkspaceTabs()!; | 18 | const workspace = useWorkspaceTabs()!; |
| 20 | const navigation = useNavigation(); | 19 | const navigation = useNavigation(); |
| 21 | const systemTabBar = useSystemWorkspaceTabBar(); | ||
| 22 | const metrics = useNativeLayoutMetrics(); | 20 | const metrics = useNativeLayoutMetrics(); |
| 23 | const nativeToolbarCompose = | 21 | const nativeToolbarCompose = |
| 24 | systemTabBar && | 22 | Platform.OS === "ios" && (Platform.isPad || (metrics?.verticalBarEdge ?? "none") !== "none"); |
| 25 | ((Platform.OS === "ios" && Platform.isPad) || (metrics?.verticalBarEdge ?? "none") !== "none"); | ||
| 26 | const { isTabActive } = workspace; | 23 | const { isTabActive } = workspace; |
| 27 | const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true; | 24 | const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true; |
| 28 | const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); | 25 | const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); |
apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts+9-2| ... | @@ -1,6 +1,13 @@ | ... | @@ -1,6 +1,13 @@ |
| 1 | import { Platform } from "react-native"; | 1 | import { Platform } from "react-native"; |
| 2 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | ||
| 2 | 3 | ||
| 3 | /** UIKit owns workspace tab navigation on every iOS size class. */ | 4 | export function useSidebarWorkspaceTabs() { |
| 5 | const { layout } = useAdaptiveWorkspaceLayout(); | ||
| 6 | return Platform.OS === "ios" && Platform.isPad && layout.usesSplitView; | ||
| 7 | } | ||
| 8 | |||
| 9 | /** Compact iOS windows use UIKit tabs; wide iPad windows use the sidebar picker. */ | ||
| 4 | export function useSystemWorkspaceTabBar() { | 10 | export function useSystemWorkspaceTabBar() { |
| 5 | return Platform.OS === "ios"; | 11 | const sidebarTabs = useSidebarWorkspaceTabs(); |
| 12 | return Platform.OS === "ios" && !sidebarTabs; | ||
| 6 | } | 13 | } |
apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx+1-1| ... | @@ -36,7 +36,7 @@ import { | ... | @@ -36,7 +36,7 @@ import { |
| 36 | excerptRows, | 36 | excerptRows, |
| 37 | fileDiffRows, | 37 | fileDiffRows, |
| 38 | rowsToPatch, | 38 | rowsToPatch, |
| 39 | } from "./guideTour.logic"; | 39 | } from "@t3tools/client-runtime/review-guide-tour"; |
| 40 | import { GuideNoteMarker } from "./GuideNotes"; | 40 | import { GuideNoteMarker } from "./GuideNotes"; |
| 41 | import { GuideProse } from "./GuideProse"; | 41 | import { GuideProse } from "./GuideProse"; |
| 42 | 42 |
apps/web/src/components/pullRequest/guide/GuideRail.tsx+1-1| ... | @@ -4,7 +4,7 @@ import { CheckIcon } from "lucide-react"; | ... | @@ -4,7 +4,7 @@ import { CheckIcon } from "lucide-react"; |
| 4 | import { cn } from "~/lib/utils"; | 4 | import { cn } from "~/lib/utils"; |
| 5 | 5 | ||
| 6 | import { Badge } from "../../ui/badge"; | 6 | import { Badge } from "../../ui/badge"; |
| 7 | import { stopSubline } from "./guideTour.logic"; | 7 | import { stopSubline } from "@t3tools/client-runtime/review-guide-tour"; |
| 8 | 8 | ||
| 9 | /** | 9 | /** |
| 10 | * The tour's table of contents: where the reader is and which stops they have read past. A stop | 10 | * The tour's table of contents: where the reader is and which stops they have read past. A stop |
apps/web/src/components/pullRequest/guide/GuideStop.tsx+1-1| ... | @@ -28,7 +28,7 @@ import { GuideExcerpt, type ExcerptCommenting } from "./GuideExcerpt"; | ... | @@ -28,7 +28,7 @@ import { GuideExcerpt, type ExcerptCommenting } from "./GuideExcerpt"; |
| 28 | import { GuideFileDiff } from "./GuideFileDiff"; | 28 | import { GuideFileDiff } from "./GuideFileDiff"; |
| 29 | import { GuideNotes } from "./GuideNotes"; | 29 | import { GuideNotes } from "./GuideNotes"; |
| 30 | import { GuideProse } from "./GuideProse"; | 30 | import { GuideProse } from "./GuideProse"; |
| 31 | import { letterStopNotes } from "./guideTour.logic"; | 31 | import { letterStopNotes } from "@t3tools/client-runtime/review-guide-tour"; |
| 32 | 32 | ||
| 33 | const ATTENTION_LABELS = { | 33 | const ATTENTION_LABELS = { |
| 34 | tradeoff: "A tradeoff to weigh", | 34 | tradeoff: "A tradeoff to weigh", |
apps/web/src/components/pullRequest/guide/GuideTour.tsx+1-1| ... | @@ -18,7 +18,7 @@ import { | ... | @@ -18,7 +18,7 @@ import { |
| 18 | useGuideRunStore, | 18 | useGuideRunStore, |
| 19 | type GuideProgress, | 19 | type GuideProgress, |
| 20 | } from "./guideRunStore"; | 20 | } from "./guideRunStore"; |
| 21 | import { stepTourPosition, stopFilePaths } from "./guideTour.logic"; | 21 | import { stepTourPosition, stopFilePaths } from "@t3tools/client-runtime/review-guide-tour"; |
| 22 | import { useGuideDiffFiles } from "./useGuideDiffFiles"; | 22 | import { useGuideDiffFiles } from "./useGuideDiffFiles"; |
| 23 | 23 | ||
| 24 | /** | 24 | /** |
apps/web/src/components/pullRequest/guide/guideTour.logic.test.ts deleted-178| ... | @@ -1,178 +0,0 @@ | ||
| 1 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; | ||
| 2 | import type { PullRequestReviewGuideStop } from "@t3tools/contracts"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | excerptExpansionOldContents, | ||
| 7 | excerptRows, | ||
| 8 | fileDiffRows, | ||
| 9 | letterStopNotes, | ||
| 10 | rowsToPatch, | ||
| 11 | stepTourPosition, | ||
| 12 | } from "./guideTour.logic"; | ||
| 13 | |||
| 14 | const PATCH = `diff --git a/src/tray.ts b/src/tray.ts | ||
| 15 | --- a/src/tray.ts | ||
| 16 | +++ b/src/tray.ts | ||
| 17 | @@ -10,5 +10,6 @@ function tray() | ||
| 18 | const a = 1; | ||
| 19 | -const size = pick(); | ||
| 20 | +const size = "xs"; | ||
| 21 | +const variant = "tray"; | ||
| 22 | const b = 2; | ||
| 23 | const c = 3; | ||
| 24 | const d = 4; | ||
| 25 | @@ -40,3 +41,2 @@ function footer() | ||
| 26 | keep(); | ||
| 27 | -dropped(); | ||
| 28 | tail(); | ||
| 29 | `; | ||
| 30 | |||
| 31 | function parse(patch: string) { | ||
| 32 | const file = parsePatchFiles(patch, `test:${patch.length}`)[0]?.files[0]; | ||
| 33 | if (file === undefined) throw new Error("patch did not parse"); | ||
| 34 | return file; | ||
| 35 | } | ||
| 36 | |||
| 37 | const rows = fileDiffRows(parse(PATCH)); | ||
| 38 | |||
| 39 | function stop(overrides: Partial<PullRequestReviewGuideStop>): PullRequestReviewGuideStop { | ||
| 40 | return { | ||
| 41 | title: "Stop", | ||
| 42 | summary: "", | ||
| 43 | attention: null, | ||
| 44 | excerpts: [], | ||
| 45 | files: [], | ||
| 46 | notes: [], | ||
| 47 | questions: [], | ||
| 48 | ...overrides, | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | |||
| 52 | describe("stepTourPosition", () => { | ||
| 53 | it("holds the tour to its first and last stops", () => { | ||
| 54 | expect(stepTourPosition(0, -1, 3)).toBe(0); | ||
| 55 | expect(stepTourPosition(0, 1, 3)).toBe(1); | ||
| 56 | expect(stepTourPosition(2, 1, 3)).toBe(2); | ||
| 57 | }); | ||
| 58 | }); | ||
| 59 | |||
| 60 | describe("fileDiffRows", () => { | ||
| 61 | it("numbers both sides through context, deletions and additions", () => { | ||
| 62 | expect(rows.map((row) => [row.kind, row.oldLine, row.newLine])).toEqual([ | ||
| 63 | ["context", 10, 10], | ||
| 64 | ["deletion", 11, null], | ||
| 65 | ["addition", null, 11], | ||
| 66 | ["addition", null, 12], | ||
| 67 | ["context", 12, 13], | ||
| 68 | ["context", 13, 14], | ||
| 69 | ["context", 14, 15], | ||
| 70 | ["context", 40, 41], | ||
| 71 | ["deletion", 41, null], | ||
| 72 | ["context", 42, 42], | ||
| 73 | ]); | ||
| 74 | }); | ||
| 75 | }); | ||
| 76 | |||
| 77 | describe("excerptRows", () => { | ||
| 78 | it("brings the replaced lines along with an excerpt that starts on an addition", () => { | ||
| 79 | const shown = excerptRows(rows, { side: "new", start: 11, end: 12 }); | ||
| 80 | expect(shown.map((row) => row.kind)).toEqual(["deletion", "addition", "addition"]); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("selects old-side lines for a removal and stops at the hunk boundary", () => { | ||
| 84 | const shown = excerptRows(rows, { side: "old", start: 41, end: 41 }); | ||
| 85 | expect(shown.map((row) => [row.kind, row.oldLine])).toEqual([["deletion", 41]]); | ||
| 86 | }); | ||
| 87 | |||
| 88 | it("returns nothing for a range outside the diff", () => { | ||
| 89 | expect(excerptRows(rows, { side: "new", start: 100, end: 120 })).toEqual([]); | ||
| 90 | }); | ||
| 91 | }); | ||
| 92 | |||
| 93 | describe("rowsToPatch", () => { | ||
| 94 | it("cuts a slice that parses back to the same lines and numbers", () => { | ||
| 95 | const shown = excerptRows(rows, { side: "new", start: 10, end: 13 }); | ||
| 96 | const slice = parse(rowsToPatch("src/tray.ts", "src/tray.ts", shown)); | ||
| 97 | expect(fileDiffRows(slice).map((row) => [row.kind, row.oldLine, row.newLine])).toEqual( | ||
| 98 | shown.map((row) => [row.kind, row.oldLine, row.newLine]), | ||
| 99 | ); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("starts a hunk per source hunk so separate hunks stay separate", () => { | ||
| 103 | const shown = excerptRows(rows, { side: "old", start: 14, end: 42 }); | ||
| 104 | const patch = rowsToPatch("src/tray.ts", "src/tray.ts", shown); | ||
| 105 | expect(patch.match(/^@@ /gm)).toHaveLength(2); | ||
| 106 | expect(patch).toContain("@@ -14,1 +15,1 @@"); | ||
| 107 | expect(patch).toContain("@@ -40,3 +41,2 @@"); | ||
| 108 | }); | ||
| 109 | }); | ||
| 110 | |||
| 111 | describe("excerptExpansionOldContents", () => { | ||
| 112 | // The new file behind PATCH: lines 1-9, the hunk at 10-15, then lines up to 41-42 around the | ||
| 113 | // second hunk, which removed a line. | ||
| 114 | const newFile = Array.from({ length: 50 }, (_, index) => `line ${index + 1}\n`); | ||
| 115 | newFile.splice( | ||
| 116 | 9, | ||
| 117 | 6, | ||
| 118 | "const a = 1;\n", | ||
| 119 | 'const size = "xs";\n', | ||
| 120 | 'const variant = "tray";\n', | ||
| 121 | "const b = 2;\n", | ||
| 122 | "const c = 3;\n", | ||
| 123 | "const d = 4;\n", | ||
| 124 | ); | ||
| 125 | newFile.splice(40, 2, "keep();\n", "tail();\n"); | ||
| 126 | const newContents = newFile.join(""); | ||
| 127 | |||
| 128 | it("puts back only the excerpt's lines and keeps both sides numbered as in the file", () => { | ||
| 129 | const shown = excerptRows(rows, { side: "new", start: 11, end: 12 }); | ||
| 130 | const old = excerptExpansionOldContents(newContents, shown); | ||
| 131 | if (old === null) throw new Error("expected contents"); | ||
| 132 | const oldLines = old.split("\n"); | ||
| 133 | // The excerpt's old side sits at its real old line. | ||
| 134 | expect(oldLines[10]).toBe("const size = pick();"); | ||
| 135 | // Around it, the new file's lines, so the two sides agree outside the excerpt. | ||
| 136 | expect(oldLines[9]).toBe("const a = 1;"); | ||
| 137 | expect(oldLines[11]).toBe("const b = 2;"); | ||
| 138 | // One line replaced by two: the old side is a line shorter, everything after lines up. | ||
| 139 | expect(oldLines.length).toBe(newContents.split("\n").length - 1); | ||
| 140 | }); | ||
| 141 | |||
| 142 | it("does not try to expand an excerpt that spans hunks", () => { | ||
| 143 | const shown = excerptRows(rows, { side: "old", start: 14, end: 42 }); | ||
| 144 | expect(excerptExpansionOldContents(newContents, shown)).toBeNull(); | ||
| 145 | }); | ||
| 146 | }); | ||
| 147 | |||
| 148 | describe("letterStopNotes", () => { | ||
| 149 | it("letters notes in guide order and pins each to the excerpt that holds its line", () => { | ||
| 150 | const { lettered, byExcerpt } = letterStopNotes( | ||
| 151 | stop({ | ||
| 152 | excerpts: [ | ||
| 153 | { path: "a.ts", side: "new", start: 1, end: 5, caption: "" }, | ||
| 154 | { path: "b.ts", side: "new", start: 10, end: 12, caption: "" }, | ||
| 155 | ], | ||
| 156 | notes: [ | ||
| 157 | { path: "b.ts", line: 11, title: "First", body: "" }, | ||
| 158 | { path: "a.ts", line: 2, title: "Second", body: "" }, | ||
| 159 | { path: "a.ts", line: 2, title: "Third", body: "" }, | ||
| 160 | { path: "c.ts", line: 1, title: "Unanchored", body: "" }, | ||
| 161 | ], | ||
| 162 | }), | ||
| 163 | ); | ||
| 164 | expect(lettered.map((entry) => entry.marker)).toEqual(["A", "B", "C", "D"]); | ||
| 165 | expect(byExcerpt).toEqual([ | ||
| 166 | [ | ||
| 167 | { | ||
| 168 | line: 2, | ||
| 169 | markers: [ | ||
| 170 | { marker: "B", title: "Second" }, | ||
| 171 | { marker: "C", title: "Third" }, | ||
| 172 | ], | ||
| 173 | }, | ||
| 174 | ], | ||
| 175 | [{ line: 11, markers: [{ marker: "A", title: "First" }] }], | ||
| 176 | ]); | ||
| 177 | }); | ||
| 178 | }); | ||
apps/web/src/components/pullRequest/guide/guideTour.logic.ts deleted-224| ... | @@ -1,224 +0,0 @@ | ||
| 1 | import type { FileDiffMetadata } from "@pierre/diffs"; | ||
| 2 | import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | /** One step forward or back, held to the tour's first and last stops. */ | ||
| 5 | export function stepTourPosition(current: number, delta: 1 | -1, stopCount: number): number { | ||
| 6 | return Math.max(Math.min(current + delta, stopCount - 1), 0); | ||
| 7 | } | ||
| 8 | |||
| 9 | /** The stop's files in reading order: the excerpted ones first, then the rest it owns. */ | ||
| 10 | export function stopFilePaths(stop: PullRequestReviewGuideStop): ReadonlyArray<string> { | ||
| 11 | return [ | ||
| 12 | ...new Set([ | ||
| 13 | ...stop.excerpts.map((excerpt) => excerpt.path), | ||
| 14 | ...stop.files.map((file) => file.path), | ||
| 15 | ]), | ||
| 16 | ]; | ||
| 17 | } | ||
| 18 | |||
| 19 | /** The rail's second line for a stop. */ | ||
| 20 | export function stopSubline(stop: PullRequestReviewGuideStop): string { | ||
| 21 | const count = stopFilePaths(stop).length; | ||
| 22 | if (count === 0) return "Context, no code"; | ||
| 23 | return count === 1 ? "1 file" : `${count} files`; | ||
| 24 | } | ||
| 25 | |||
| 26 | /** A note's marker: A, B, C… in the order the guide lists them, numbers past Z. */ | ||
| 27 | function noteMarker(index: number): string { | ||
| 28 | return index < 26 ? String.fromCharCode(65 + index) : String(index + 1); | ||
| 29 | } | ||
| 30 | |||
| 31 | /** One rendered line of a file's diff, with where it sits on each side. */ | ||
| 32 | export interface DiffRow { | ||
| 33 | readonly kind: "context" | "addition" | "deletion"; | ||
| 34 | /** Line number on the old side, null for an added line. */ | ||
| 35 | readonly oldLine: number | null; | ||
| 36 | /** Line number on the new side, null for a removed line. */ | ||
| 37 | readonly newLine: number | null; | ||
| 38 | /** The next line number on each side when this row is reached, for hunk headers. */ | ||
| 39 | readonly oldAt: number; | ||
| 40 | readonly newAt: number; | ||
| 41 | readonly text: string; | ||
| 42 | /** Which hunk of the file the row came from; rows from two hunks are not adjacent. */ | ||
| 43 | readonly hunk: number; | ||
| 44 | } | ||
| 45 | |||
| 46 | /** Every line of a parsed file diff as the unified view lists it. */ | ||
| 47 | export function fileDiffRows(file: FileDiffMetadata): ReadonlyArray<DiffRow> { | ||
| 48 | const rows: DiffRow[] = []; | ||
| 49 | file.hunks.forEach((hunk, hunkIndex) => { | ||
| 50 | let oldAt = hunk.deletionStart; | ||
| 51 | let newAt = hunk.additionStart; | ||
| 52 | for (const content of hunk.hunkContent) { | ||
| 53 | if (content.type === "context") { | ||
| 54 | for (let offset = 0; offset < content.lines; offset += 1) { | ||
| 55 | rows.push({ | ||
| 56 | kind: "context", | ||
| 57 | oldLine: oldAt, | ||
| 58 | newLine: newAt, | ||
| 59 | oldAt, | ||
| 60 | newAt, | ||
| 61 | text: file.additionLines[content.additionLineIndex + offset] ?? "", | ||
| 62 | hunk: hunkIndex, | ||
| 63 | }); | ||
| 64 | oldAt += 1; | ||
| 65 | newAt += 1; | ||
| 66 | } | ||
| 67 | continue; | ||
| 68 | } | ||
| 69 | for (let offset = 0; offset < content.deletions; offset += 1) { | ||
| 70 | rows.push({ | ||
| 71 | kind: "deletion", | ||
| 72 | oldLine: oldAt, | ||
| 73 | newLine: null, | ||
| 74 | oldAt, | ||
| 75 | newAt, | ||
| 76 | text: file.deletionLines[content.deletionLineIndex + offset] ?? "", | ||
| 77 | hunk: hunkIndex, | ||
| 78 | }); | ||
| 79 | oldAt += 1; | ||
| 80 | } | ||
| 81 | for (let offset = 0; offset < content.additions; offset += 1) { | ||
| 82 | rows.push({ | ||
| 83 | kind: "addition", | ||
| 84 | oldLine: null, | ||
| 85 | newLine: newAt, | ||
| 86 | oldAt, | ||
| 87 | newAt, | ||
| 88 | text: file.additionLines[content.additionLineIndex + offset] ?? "", | ||
| 89 | hunk: hunkIndex, | ||
| 90 | }); | ||
| 91 | newAt += 1; | ||
| 92 | } | ||
| 93 | } | ||
| 94 | }); | ||
| 95 | return rows; | ||
| 96 | } | ||
| 97 | |||
| 98 | /** | ||
| 99 | * The rows an excerpt covers. Its range is in one side's line numbers, so the other side's half | ||
| 100 | * of a replacement at either edge comes along: an excerpt starting on an added line also shows | ||
| 101 | * the lines it replaced, which is what makes it read as a change rather than as new code. | ||
| 102 | */ | ||
| 103 | export function excerptRows( | ||
| 104 | rows: ReadonlyArray<DiffRow>, | ||
| 105 | excerpt: Pick<PullRequestReviewGuideExcerpt, "side" | "start" | "end">, | ||
| 106 | ): ReadonlyArray<DiffRow> { | ||
| 107 | const lineOf = (row: DiffRow) => (excerpt.side === "new" ? row.newLine : row.oldLine); | ||
| 108 | const inRange = (row: DiffRow) => { | ||
| 109 | const line = lineOf(row); | ||
| 110 | return line !== null && line >= excerpt.start && line <= excerpt.end; | ||
| 111 | }; | ||
| 112 | let first = rows.findIndex(inRange); | ||
| 113 | if (first === -1) return []; | ||
| 114 | let last = rows.findLastIndex(inRange); | ||
| 115 | const sameHunk = (index: number, anchor: number) => rows[index]?.hunk === rows[anchor]?.hunk; | ||
| 116 | if (excerpt.side === "new" && rows[first]?.kind === "addition") { | ||
| 117 | while (first > 0 && rows[first - 1]?.kind === "deletion" && sameHunk(first - 1, first)) { | ||
| 118 | first -= 1; | ||
| 119 | } | ||
| 120 | } | ||
| 121 | if (excerpt.side === "old" && rows[last]?.kind === "deletion") { | ||
| 122 | while ( | ||
| 123 | last < rows.length - 1 && | ||
| 124 | rows[last + 1]?.kind === "addition" && | ||
| 125 | sameHunk(last + 1, last) | ||
| 126 | ) { | ||
| 127 | last += 1; | ||
| 128 | } | ||
| 129 | } | ||
| 130 | return rows.slice(first, last + 1); | ||
| 131 | } | ||
| 132 | |||
| 133 | const ROW_PREFIX = { context: " ", addition: "+", deletion: "-" } as const; | ||
| 134 | |||
| 135 | /** | ||
| 136 | * A unified patch of just these rows, so the shared diff renderer can draw a slice of a file | ||
| 137 | * with its real line numbers instead of a second diff view being written for excerpts. | ||
| 138 | */ | ||
| 139 | export function rowsToPatch( | ||
| 140 | path: string, | ||
| 141 | previousPath: string, | ||
| 142 | rows: ReadonlyArray<DiffRow>, | ||
| 143 | ): string { | ||
| 144 | if (rows.length === 0) return ""; | ||
| 145 | const lines = [ | ||
| 146 | `diff --git a/${previousPath} b/${path}`, | ||
| 147 | `--- a/${previousPath}`, | ||
| 148 | `+++ b/${path}`, | ||
| 149 | ]; | ||
| 150 | let start = 0; | ||
| 151 | while (start < rows.length) { | ||
| 152 | let end = start; | ||
| 153 | while (end + 1 < rows.length && rows[end + 1]?.hunk === rows[start]?.hunk) end += 1; | ||
| 154 | const group = rows.slice(start, end + 1); | ||
| 155 | const head = group[0]!; | ||
| 156 | const oldCount = group.filter((row) => row.oldLine !== null).length; | ||
| 157 | const newCount = group.filter((row) => row.newLine !== null).length; | ||
| 158 | // Git names the line before an empty side, so a pure insertion after line 9 is `-9,0`. | ||
| 159 | const oldStart = oldCount === 0 ? Math.max(head.oldAt - 1, 0) : head.oldAt; | ||
| 160 | const newStart = newCount === 0 ? Math.max(head.newAt - 1, 0) : head.newAt; | ||
| 161 | lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`); | ||
| 162 | for (const row of group) lines.push(`${ROW_PREFIX[row.kind]}${row.text.replace(/\r?\n$/, "")}`); | ||
| 163 | start = end + 1; | ||
| 164 | } | ||
| 165 | return `${lines.join("\n")}\n`; | ||
| 166 | } | ||
| 167 | |||
| 168 | /** | ||
| 169 | * The old side an excerpt expands against: the new file with only the excerpt's lines put back | ||
| 170 | * as they were. The renderer fills the code around a hunk from both files and needs them to | ||
| 171 | * agree outside it, which the real old file does not wherever another hunk changed lines. Only | ||
| 172 | * the lines the reader can expand into are read, and those are the new file's, so the code | ||
| 173 | * around the excerpt shows as it reads after the change. Null when the excerpt spans hunks. | ||
| 174 | */ | ||
| 175 | export function excerptExpansionOldContents( | ||
| 176 | newContents: string, | ||
| 177 | rows: ReadonlyArray<DiffRow>, | ||
| 178 | ): string | null { | ||
| 179 | const head = rows[0]; | ||
| 180 | if (head === undefined || rows.some((row) => row.hunk !== head.hunk)) return null; | ||
| 181 | const newLines = newContents.match(/[^\n]*\n|[^\n]+$/g) ?? []; | ||
| 182 | const before = head.newAt - 1; | ||
| 183 | const newCount = rows.filter((row) => row.newLine !== null).length; | ||
| 184 | // The old side keeps its own numbering, so its prefix is as long as the old file's: trimmed | ||
| 185 | // from the front, or padded there with lines no expansion reaches. | ||
| 186 | const oldBefore = head.oldAt - 1; | ||
| 187 | const prefix = newLines.slice(0, before); | ||
| 188 | const oldPrefix = | ||
| 189 | oldBefore >= prefix.length | ||
| 190 | ? [...Array.from({ length: oldBefore - prefix.length }, () => "\n"), ...prefix] | ||
| 191 | : prefix.slice(prefix.length - oldBefore); | ||
| 192 | const oldRows = rows | ||
| 193 | .filter((row) => row.oldLine !== null) | ||
| 194 | .map((row) => (row.text.endsWith("\n") ? row.text : `${row.text}\n`)); | ||
| 195 | return [...oldPrefix, ...oldRows, ...newLines.slice(before + newCount)].join(""); | ||
| 196 | } | ||
| 197 | |||
| 198 | /** A stop's notes lettered in guide order, with the excerpt lines each one marks. */ | ||
| 199 | export interface LetteredNote<Note> { | ||
| 200 | readonly marker: string; | ||
| 201 | readonly note: Note; | ||
| 202 | } | ||
| 203 | |||
| 204 | /** | ||
| 205 | * Letters a stop's notes and groups them by the excerpt line they sit on. A note outside every | ||
| 206 | * excerpt keeps its letter in the list; it just has no line to mark. | ||
| 207 | */ | ||
| 208 | export function letterStopNotes(stop: PullRequestReviewGuideStop) { | ||
| 209 | const lettered = stop.notes.map((note, index) => ({ marker: noteMarker(index), note })); | ||
| 210 | const byExcerpt = stop.excerpts.map((excerpt) => { | ||
| 211 | const lines = new Map<number, Array<{ marker: string; title: string }>>(); | ||
| 212 | for (const { marker, note } of lettered) { | ||
| 213 | if (note.path !== excerpt.path || note.line < excerpt.start || note.line > excerpt.end) { | ||
| 214 | continue; | ||
| 215 | } | ||
| 216 | const entry = { marker, title: note.title }; | ||
| 217 | const list = lines.get(note.line); | ||
| 218 | if (list === undefined) lines.set(note.line, [entry]); | ||
| 219 | else list.push(entry); | ||
| 220 | } | ||
| 221 | return [...lines].map(([line, markers]) => ({ line, markers })); | ||
| 222 | }); | ||
| 223 | return { lettered, byExcerpt }; | ||
| 224 | } | ||
docs/user/source-control.md+6-3| ... | @@ -193,7 +193,9 @@ declined pull request. | ... | @@ -193,7 +193,9 @@ declined pull request. |
| 193 | ### Review guides | 193 | ### Review guides |
| 194 | 194 | ||
| 195 | Review guides are in beta. Turn on **Review guides** in Settings → Source Control to add the | 195 | Review guides are in beta. Turn on **Review guides** in Settings → Source Control to add the |
| 196 | **Guide** tab to pull requests on that server. | 196 | **Guide** tab to pull requests on that server. On mobile, move between stops with **Next** |
| 197 | and the back button; a stale guide offers a rewrite and hides excerpts that no longer match | ||
| 198 | the current change. | ||
| 197 | 199 | ||
| 198 | The **Guide** tab gives you a tour of a pull request without reading it line by line: stops | 200 | The **Guide** tab gives you a tour of a pull request without reading it line by line: stops |
| 199 | through the change in reading order. Each stop points at the lines worth reading, and a stop marked | 201 | through the change in reading order. Each stop points at the lines worth reading, and a stop marked |
| ... | @@ -205,11 +207,12 @@ covers that layer only, and may point out what a lower layer introduced or what | ... | @@ -205,11 +207,12 @@ covers that layer only, and may point out what a lower layer introduced or what |
| 205 | Choose **Write guide** to have the review guide model from Settings → Source Control read the | 207 | Choose **Write guide** to have the review guide model from Settings → Source Control read the |
| 206 | change in a temporary checkout; without one set, the source control writer model is used. Nothing | 208 | change in a temporary checkout; without one set, the source control writer model is used. Nothing |
| 207 | in the checkout is changed, and a large pull request takes a few minutes; you can go elsewhere in | 209 | in the checkout is changed, and a large pull request takes a few minutes; you can go elsewhere in |
| 208 | the app and you are told when the guide is ready. Claude and Codex can write guides. The guide is | 210 | the app. On web, you are told when the guide is ready; on mobile, return to the guide to check |
| 211 | its progress. Claude and Codex can write guides. The guide is | ||
| 209 | kept for everyone using this server. To write it again, for example after a push or after changing | 212 | kept for everyone using this server. To write it again, for example after a push or after changing |
| 210 | the model or instructions, choose **Rewrite guide** from the pull request's **⋯** menu. | 213 | the model or instructions, choose **Rewrite guide** from the pull request's **⋯** menu. |
| 211 | 214 | ||
| 212 | While reading a stop, select lines in an excerpt to add a comment to your review or ask an agent | 215 | On web, while reading a stop, select lines in an excerpt to add a comment to your review or ask an agent |
| 213 | about them; comments wait with the ones from the **Code** tab until you send the review. Choose a | 216 | about them; comments wait with the ones from the **Code** tab until you send the review. Choose a |
| 214 | suggested question, or **Ask about this stop**, to open a thread that already knows which stop you | 217 | suggested question, or **Ask about this stop**, to open a thread that already knows which stop you |
| 215 | are on. **Finish review** on the last stop opens the review with everything you collected. | 218 | are on. **Finish review** on the last stop opens the review with everything you collected. |
packages/client-runtime/package.json+5| ... | @@ -398,6 +398,10 @@ | ... | @@ -398,6 +398,10 @@ |
| 398 | "./source-control-clients": { | 398 | "./source-control-clients": { |
| 399 | "types": "./src/sourceControlClients.ts", | 399 | "types": "./src/sourceControlClients.ts", |
| 400 | "default": "./src/sourceControlClients.ts" | 400 | "default": "./src/sourceControlClients.ts" |
| 401 | }, | ||
| 402 | "./review-guide-tour": { | ||
| 403 | "types": "./src/reviewGuideTour.ts", | ||
| 404 | "default": "./src/reviewGuideTour.ts" | ||
| 401 | } | 405 | } |
| 402 | }, | 406 | }, |
| 403 | "scripts": { | 407 | "scripts": { |
| ... | @@ -405,6 +409,7 @@ | ... | @@ -405,6 +409,7 @@ |
| 405 | "test": "vp test run" | 409 | "test": "vp test run" |
| 406 | }, | 410 | }, |
| 407 | "dependencies": { | 411 | "dependencies": { |
| 412 | "@pierre/diffs": "catalog:", | ||
| 408 | "@t3tools/contracts": "workspace:*", | 413 | "@t3tools/contracts": "workspace:*", |
| 409 | "@t3tools/shared": "workspace:*", | 414 | "@t3tools/shared": "workspace:*", |
| 410 | "@t3tools/source-control-azure-devops": "workspace:*", | 415 | "@t3tools/source-control-azure-devops": "workspace:*", |
packages/client-runtime/src/reviewGuideTour.test.ts created+178| ... | @@ -0,0 +1,178 @@ | ||
| 1 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; | ||
| 2 | import type { PullRequestReviewGuideStop } from "@t3tools/contracts"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | excerptExpansionOldContents, | ||
| 7 | excerptRows, | ||
| 8 | fileDiffRows, | ||
| 9 | letterStopNotes, | ||
| 10 | rowsToPatch, | ||
| 11 | stepTourPosition, | ||
| 12 | } from "./reviewGuideTour.js"; | ||
| 13 | |||
| 14 | const PATCH = `diff --git a/src/tray.ts b/src/tray.ts | ||
| 15 | --- a/src/tray.ts | ||
| 16 | +++ b/src/tray.ts | ||
| 17 | @@ -10,5 +10,6 @@ function tray() | ||
| 18 | const a = 1; | ||
| 19 | -const size = pick(); | ||
| 20 | +const size = "xs"; | ||
| 21 | +const variant = "tray"; | ||
| 22 | const b = 2; | ||
| 23 | const c = 3; | ||
| 24 | const d = 4; | ||
| 25 | @@ -40,3 +41,2 @@ function footer() | ||
| 26 | keep(); | ||
| 27 | -dropped(); | ||
| 28 | tail(); | ||
| 29 | `; | ||
| 30 | |||
| 31 | function parse(patch: string) { | ||
| 32 | const file = parsePatchFiles(patch, `test:${patch.length}`)[0]?.files[0]; | ||
| 33 | if (file === undefined) throw new Error("patch did not parse"); | ||
| 34 | return file; | ||
| 35 | } | ||
| 36 | |||
| 37 | const rows = fileDiffRows(parse(PATCH)); | ||
| 38 | |||
| 39 | function stop(overrides: Partial<PullRequestReviewGuideStop>): PullRequestReviewGuideStop { | ||
| 40 | return { | ||
| 41 | title: "Stop", | ||
| 42 | summary: "", | ||
| 43 | attention: null, | ||
| 44 | excerpts: [], | ||
| 45 | files: [], | ||
| 46 | notes: [], | ||
| 47 | questions: [], | ||
| 48 | ...overrides, | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | |||
| 52 | describe("stepTourPosition", () => { | ||
| 53 | it("holds the tour to its first and last stops", () => { | ||
| 54 | expect(stepTourPosition(0, -1, 3)).toBe(0); | ||
| 55 | expect(stepTourPosition(0, 1, 3)).toBe(1); | ||
| 56 | expect(stepTourPosition(2, 1, 3)).toBe(2); | ||
| 57 | }); | ||
| 58 | }); | ||
| 59 | |||
| 60 | describe("fileDiffRows", () => { | ||
| 61 | it("numbers both sides through context, deletions and additions", () => { | ||
| 62 | expect(rows.map((row) => [row.kind, row.oldLine, row.newLine])).toEqual([ | ||
| 63 | ["context", 10, 10], | ||
| 64 | ["deletion", 11, null], | ||
| 65 | ["addition", null, 11], | ||
| 66 | ["addition", null, 12], | ||
| 67 | ["context", 12, 13], | ||
| 68 | ["context", 13, 14], | ||
| 69 | ["context", 14, 15], | ||
| 70 | ["context", 40, 41], | ||
| 71 | ["deletion", 41, null], | ||
| 72 | ["context", 42, 42], | ||
| 73 | ]); | ||
| 74 | }); | ||
| 75 | }); | ||
| 76 | |||
| 77 | describe("excerptRows", () => { | ||
| 78 | it("brings the replaced lines along with an excerpt that starts on an addition", () => { | ||
| 79 | const shown = excerptRows(rows, { side: "new", start: 11, end: 12 }); | ||
| 80 | expect(shown.map((row) => row.kind)).toEqual(["deletion", "addition", "addition"]); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("selects old-side lines for a removal and stops at the hunk boundary", () => { | ||
| 84 | const shown = excerptRows(rows, { side: "old", start: 41, end: 41 }); | ||
| 85 | expect(shown.map((row) => [row.kind, row.oldLine])).toEqual([["deletion", 41]]); | ||
| 86 | }); | ||
| 87 | |||
| 88 | it("returns nothing for a range outside the diff", () => { | ||
| 89 | expect(excerptRows(rows, { side: "new", start: 100, end: 120 })).toEqual([]); | ||
| 90 | }); | ||
| 91 | }); | ||
| 92 | |||
| 93 | describe("rowsToPatch", () => { | ||
| 94 | it("cuts a slice that parses back to the same lines and numbers", () => { | ||
| 95 | const shown = excerptRows(rows, { side: "new", start: 10, end: 13 }); | ||
| 96 | const slice = parse(rowsToPatch("src/tray.ts", "src/tray.ts", shown)); | ||
| 97 | expect(fileDiffRows(slice).map((row) => [row.kind, row.oldLine, row.newLine])).toEqual( | ||
| 98 | shown.map((row) => [row.kind, row.oldLine, row.newLine]), | ||
| 99 | ); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("starts a hunk per source hunk so separate hunks stay separate", () => { | ||
| 103 | const shown = excerptRows(rows, { side: "old", start: 14, end: 42 }); | ||
| 104 | const patch = rowsToPatch("src/tray.ts", "src/tray.ts", shown); | ||
| 105 | expect(patch.match(/^@@ /gm)).toHaveLength(2); | ||
| 106 | expect(patch).toContain("@@ -14,1 +15,1 @@"); | ||
| 107 | expect(patch).toContain("@@ -40,3 +41,2 @@"); | ||
| 108 | }); | ||
| 109 | }); | ||
| 110 | |||
| 111 | describe("excerptExpansionOldContents", () => { | ||
| 112 | // The new file behind PATCH: lines 1-9, the hunk at 10-15, then lines up to 41-42 around the | ||
| 113 | // second hunk, which removed a line. | ||
| 114 | const newFile = Array.from({ length: 50 }, (_, index) => `line ${index + 1}\n`); | ||
| 115 | newFile.splice( | ||
| 116 | 9, | ||
| 117 | 6, | ||
| 118 | "const a = 1;\n", | ||
| 119 | 'const size = "xs";\n', | ||
| 120 | 'const variant = "tray";\n', | ||
| 121 | "const b = 2;\n", | ||
| 122 | "const c = 3;\n", | ||
| 123 | "const d = 4;\n", | ||
| 124 | ); | ||
| 125 | newFile.splice(40, 2, "keep();\n", "tail();\n"); | ||
| 126 | const newContents = newFile.join(""); | ||
| 127 | |||
| 128 | it("puts back only the excerpt's lines and keeps both sides numbered as in the file", () => { | ||
| 129 | const shown = excerptRows(rows, { side: "new", start: 11, end: 12 }); | ||
| 130 | const old = excerptExpansionOldContents(newContents, shown); | ||
| 131 | if (old === null) throw new Error("expected contents"); | ||
| 132 | const oldLines = old.split("\n"); | ||
| 133 | // The excerpt's old side sits at its real old line. | ||
| 134 | expect(oldLines[10]).toBe("const size = pick();"); | ||
| 135 | // Around it, the new file's lines, so the two sides agree outside the excerpt. | ||
| 136 | expect(oldLines[9]).toBe("const a = 1;"); | ||
| 137 | expect(oldLines[11]).toBe("const b = 2;"); | ||
| 138 | // One line replaced by two: the old side is a line shorter, everything after lines up. | ||
| 139 | expect(oldLines.length).toBe(newContents.split("\n").length - 1); | ||
| 140 | }); | ||
| 141 | |||
| 142 | it("does not try to expand an excerpt that spans hunks", () => { | ||
| 143 | const shown = excerptRows(rows, { side: "old", start: 14, end: 42 }); | ||
| 144 | expect(excerptExpansionOldContents(newContents, shown)).toBeNull(); | ||
| 145 | }); | ||
| 146 | }); | ||
| 147 | |||
| 148 | describe("letterStopNotes", () => { | ||
| 149 | it("letters notes in guide order and pins each to the excerpt that holds its line", () => { | ||
| 150 | const { lettered, byExcerpt } = letterStopNotes( | ||
| 151 | stop({ | ||
| 152 | excerpts: [ | ||
| 153 | { path: "a.ts", side: "new", start: 1, end: 5, caption: "" }, | ||
| 154 | { path: "b.ts", side: "new", start: 10, end: 12, caption: "" }, | ||
| 155 | ], | ||
| 156 | notes: [ | ||
| 157 | { path: "b.ts", line: 11, title: "First", body: "" }, | ||
| 158 | { path: "a.ts", line: 2, title: "Second", body: "" }, | ||
| 159 | { path: "a.ts", line: 2, title: "Third", body: "" }, | ||
| 160 | { path: "c.ts", line: 1, title: "Unanchored", body: "" }, | ||
| 161 | ], | ||
| 162 | }), | ||
| 163 | ); | ||
| 164 | expect(lettered.map((entry) => entry.marker)).toEqual(["A", "B", "C", "D"]); | ||
| 165 | expect(byExcerpt).toEqual([ | ||
| 166 | [ | ||
| 167 | { | ||
| 168 | line: 2, | ||
| 169 | markers: [ | ||
| 170 | { marker: "B", title: "Second" }, | ||
| 171 | { marker: "C", title: "Third" }, | ||
| 172 | ], | ||
| 173 | }, | ||
| 174 | ], | ||
| 175 | [{ line: 11, markers: [{ marker: "A", title: "First" }] }], | ||
| 176 | ]); | ||
| 177 | }); | ||
| 178 | }); | ||
packages/client-runtime/src/reviewGuideTour.ts created+224| ... | @@ -0,0 +1,224 @@ | ||
| 1 | import type { FileDiffMetadata } from "@pierre/diffs"; | ||
| 2 | import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | /** One step forward or back, held to the tour's first and last stops. */ | ||
| 5 | export function stepTourPosition(current: number, delta: 1 | -1, stopCount: number): number { | ||
| 6 | return Math.max(Math.min(current + delta, stopCount - 1), 0); | ||
| 7 | } | ||
| 8 | |||
| 9 | /** The stop's files in reading order: the excerpted ones first, then the rest it owns. */ | ||
| 10 | export function stopFilePaths(stop: PullRequestReviewGuideStop): ReadonlyArray<string> { | ||
| 11 | return [ | ||
| 12 | ...new Set([ | ||
| 13 | ...stop.excerpts.map((excerpt) => excerpt.path), | ||
| 14 | ...stop.files.map((file) => file.path), | ||
| 15 | ]), | ||
| 16 | ]; | ||
| 17 | } | ||
| 18 | |||
| 19 | /** The rail's second line for a stop. */ | ||
| 20 | export function stopSubline(stop: PullRequestReviewGuideStop): string { | ||
| 21 | const count = stopFilePaths(stop).length; | ||
| 22 | if (count === 0) return "Context, no code"; | ||
| 23 | return count === 1 ? "1 file" : `${count} files`; | ||
| 24 | } | ||
| 25 | |||
| 26 | /** A note's marker: A, B, C… in the order the guide lists them, numbers past Z. */ | ||
| 27 | function noteMarker(index: number): string { | ||
| 28 | return index < 26 ? String.fromCharCode(65 + index) : String(index + 1); | ||
| 29 | } | ||
| 30 | |||
| 31 | /** One rendered line of a file's diff, with where it sits on each side. */ | ||
| 32 | export interface DiffRow { | ||
| 33 | readonly kind: "context" | "addition" | "deletion"; | ||
| 34 | /** Line number on the old side, null for an added line. */ | ||
| 35 | readonly oldLine: number | null; | ||
| 36 | /** Line number on the new side, null for a removed line. */ | ||
| 37 | readonly newLine: number | null; | ||
| 38 | /** The next line number on each side when this row is reached, for hunk headers. */ | ||
| 39 | readonly oldAt: number; | ||
| 40 | readonly newAt: number; | ||
| 41 | readonly text: string; | ||
| 42 | /** Which hunk of the file the row came from; rows from two hunks are not adjacent. */ | ||
| 43 | readonly hunk: number; | ||
| 44 | } | ||
| 45 | |||
| 46 | /** Every line of a parsed file diff as the unified view lists it. */ | ||
| 47 | export function fileDiffRows(file: FileDiffMetadata): ReadonlyArray<DiffRow> { | ||
| 48 | const rows: DiffRow[] = []; | ||
| 49 | file.hunks.forEach((hunk, hunkIndex) => { | ||
| 50 | let oldAt = hunk.deletionStart; | ||
| 51 | let newAt = hunk.additionStart; | ||
| 52 | for (const content of hunk.hunkContent) { | ||
| 53 | if (content.type === "context") { | ||
| 54 | for (let offset = 0; offset < content.lines; offset += 1) { | ||
| 55 | rows.push({ | ||
| 56 | kind: "context", | ||
| 57 | oldLine: oldAt, | ||
| 58 | newLine: newAt, | ||
| 59 | oldAt, | ||
| 60 | newAt, | ||
| 61 | text: file.additionLines[content.additionLineIndex + offset] ?? "", | ||
| 62 | hunk: hunkIndex, | ||
| 63 | }); | ||
| 64 | oldAt += 1; | ||
| 65 | newAt += 1; | ||
| 66 | } | ||
| 67 | continue; | ||
| 68 | } | ||
| 69 | for (let offset = 0; offset < content.deletions; offset += 1) { | ||
| 70 | rows.push({ | ||
| 71 | kind: "deletion", | ||
| 72 | oldLine: oldAt, | ||
| 73 | newLine: null, | ||
| 74 | oldAt, | ||
| 75 | newAt, | ||
| 76 | text: file.deletionLines[content.deletionLineIndex + offset] ?? "", | ||
| 77 | hunk: hunkIndex, | ||
| 78 | }); | ||
| 79 | oldAt += 1; | ||
| 80 | } | ||
| 81 | for (let offset = 0; offset < content.additions; offset += 1) { | ||
| 82 | rows.push({ | ||
| 83 | kind: "addition", | ||
| 84 | oldLine: null, | ||
| 85 | newLine: newAt, | ||
| 86 | oldAt, | ||
| 87 | newAt, | ||
| 88 | text: file.additionLines[content.additionLineIndex + offset] ?? "", | ||
| 89 | hunk: hunkIndex, | ||
| 90 | }); | ||
| 91 | newAt += 1; | ||
| 92 | } | ||
| 93 | } | ||
| 94 | }); | ||
| 95 | return rows; | ||
| 96 | } | ||
| 97 | |||
| 98 | /** | ||
| 99 | * The rows an excerpt covers. Its range is in one side's line numbers, so the other side's half | ||
| 100 | * of a replacement at either edge comes along: an excerpt starting on an added line also shows | ||
| 101 | * the lines it replaced, which is what makes it read as a change rather than as new code. | ||
| 102 | */ | ||
| 103 | export function excerptRows( | ||
| 104 | rows: ReadonlyArray<DiffRow>, | ||
| 105 | excerpt: Pick<PullRequestReviewGuideExcerpt, "side" | "start" | "end">, | ||
| 106 | ): ReadonlyArray<DiffRow> { | ||
| 107 | const lineOf = (row: DiffRow) => (excerpt.side === "new" ? row.newLine : row.oldLine); | ||
| 108 | const inRange = (row: DiffRow) => { | ||
| 109 | const line = lineOf(row); | ||
| 110 | return line !== null && line >= excerpt.start && line <= excerpt.end; | ||
| 111 | }; | ||
| 112 | let first = rows.findIndex(inRange); | ||
| 113 | if (first === -1) return []; | ||
| 114 | let last = rows.findLastIndex(inRange); | ||
| 115 | const sameHunk = (index: number, anchor: number) => rows[index]?.hunk === rows[anchor]?.hunk; | ||
| 116 | if (excerpt.side === "new" && rows[first]?.kind === "addition") { | ||
| 117 | while (first > 0 && rows[first - 1]?.kind === "deletion" && sameHunk(first - 1, first)) { | ||
| 118 | first -= 1; | ||
| 119 | } | ||
| 120 | } | ||
| 121 | if (excerpt.side === "old" && rows[last]?.kind === "deletion") { | ||
| 122 | while ( | ||
| 123 | last < rows.length - 1 && | ||
| 124 | rows[last + 1]?.kind === "addition" && | ||
| 125 | sameHunk(last + 1, last) | ||
| 126 | ) { | ||
| 127 | last += 1; | ||
| 128 | } | ||
| 129 | } | ||
| 130 | return rows.slice(first, last + 1); | ||
| 131 | } | ||
| 132 | |||
| 133 | const ROW_PREFIX = { context: " ", addition: "+", deletion: "-" } as const; | ||
| 134 | |||
| 135 | /** | ||
| 136 | * A unified patch of just these rows, so the shared diff renderer can draw a slice of a file | ||
| 137 | * with its real line numbers instead of a second diff view being written for excerpts. | ||
| 138 | */ | ||
| 139 | export function rowsToPatch( | ||
| 140 | path: string, | ||
| 141 | previousPath: string, | ||
| 142 | rows: ReadonlyArray<DiffRow>, | ||
| 143 | ): string { | ||
| 144 | if (rows.length === 0) return ""; | ||
| 145 | const lines = [ | ||
| 146 | `diff --git a/${previousPath} b/${path}`, | ||
| 147 | `--- a/${previousPath}`, | ||
| 148 | `+++ b/${path}`, | ||
| 149 | ]; | ||
| 150 | let start = 0; | ||
| 151 | while (start < rows.length) { | ||
| 152 | let end = start; | ||
| 153 | while (end + 1 < rows.length && rows[end + 1]?.hunk === rows[start]?.hunk) end += 1; | ||
| 154 | const group = rows.slice(start, end + 1); | ||
| 155 | const head = group[0]!; | ||
| 156 | const oldCount = group.filter((row) => row.oldLine !== null).length; | ||
| 157 | const newCount = group.filter((row) => row.newLine !== null).length; | ||
| 158 | // Git names the line before an empty side, so a pure insertion after line 9 is `-9,0`. | ||
| 159 | const oldStart = oldCount === 0 ? Math.max(head.oldAt - 1, 0) : head.oldAt; | ||
| 160 | const newStart = newCount === 0 ? Math.max(head.newAt - 1, 0) : head.newAt; | ||
| 161 | lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`); | ||
| 162 | for (const row of group) lines.push(`${ROW_PREFIX[row.kind]}${row.text.replace(/\r?\n$/, "")}`); | ||
| 163 | start = end + 1; | ||
| 164 | } | ||
| 165 | return `${lines.join("\n")}\n`; | ||
| 166 | } | ||
| 167 | |||
| 168 | /** | ||
| 169 | * The old side an excerpt expands against: the new file with only the excerpt's lines put back | ||
| 170 | * as they were. The renderer fills the code around a hunk from both files and needs them to | ||
| 171 | * agree outside it, which the real old file does not wherever another hunk changed lines. Only | ||
| 172 | * the lines the reader can expand into are read, and those are the new file's, so the code | ||
| 173 | * around the excerpt shows as it reads after the change. Null when the excerpt spans hunks. | ||
| 174 | */ | ||
| 175 | export function excerptExpansionOldContents( | ||
| 176 | newContents: string, | ||
| 177 | rows: ReadonlyArray<DiffRow>, | ||
| 178 | ): string | null { | ||
| 179 | const head = rows[0]; | ||
| 180 | if (head === undefined || rows.some((row) => row.hunk !== head.hunk)) return null; | ||
| 181 | const newLines = newContents.match(/[^\n]*\n|[^\n]+$/g) ?? []; | ||
| 182 | const before = head.newAt - 1; | ||
| 183 | const newCount = rows.filter((row) => row.newLine !== null).length; | ||
| 184 | // The old side keeps its own numbering, so its prefix is as long as the old file's: trimmed | ||
| 185 | // from the front, or padded there with lines no expansion reaches. | ||
| 186 | const oldBefore = head.oldAt - 1; | ||
| 187 | const prefix = newLines.slice(0, before); | ||
| 188 | const oldPrefix = | ||
| 189 | oldBefore >= prefix.length | ||
| 190 | ? [...Array.from({ length: oldBefore - prefix.length }, () => "\n"), ...prefix] | ||
| 191 | : prefix.slice(prefix.length - oldBefore); | ||
| 192 | const oldRows = rows | ||
| 193 | .filter((row) => row.oldLine !== null) | ||
| 194 | .map((row) => (row.text.endsWith("\n") ? row.text : `${row.text}\n`)); | ||
| 195 | return [...oldPrefix, ...oldRows, ...newLines.slice(before + newCount)].join(""); | ||
| 196 | } | ||
| 197 | |||
| 198 | /** A stop's notes lettered in guide order, with the excerpt lines each one marks. */ | ||
| 199 | export interface LetteredNote<Note> { | ||
| 200 | readonly marker: string; | ||
| 201 | readonly note: Note; | ||
| 202 | } | ||
| 203 | |||
| 204 | /** | ||
| 205 | * Letters a stop's notes and groups them by the excerpt line they sit on. A note outside every | ||
| 206 | * excerpt keeps its letter in the list; it just has no line to mark. | ||
| 207 | */ | ||
| 208 | export function letterStopNotes(stop: PullRequestReviewGuideStop) { | ||
| 209 | const lettered = stop.notes.map((note, index) => ({ marker: noteMarker(index), note })); | ||
| 210 | const byExcerpt = stop.excerpts.map((excerpt) => { | ||
| 211 | const lines = new Map<number, Array<{ marker: string; title: string }>>(); | ||
| 212 | for (const { marker, note } of lettered) { | ||
| 213 | if (note.path !== excerpt.path || note.line < excerpt.start || note.line > excerpt.end) { | ||
| 214 | continue; | ||
| 215 | } | ||
| 216 | const entry = { marker, title: note.title }; | ||
| 217 | const list = lines.get(note.line); | ||
| 218 | if (list === undefined) lines.set(note.line, [entry]); | ||
| 219 | else list.push(entry); | ||
| 220 | } | ||
| 221 | return [...lines].map(([line, markers]) => ({ line, markers })); | ||
| 222 | }); | ||
| 223 | return { lettered, byExcerpt }; | ||
| 224 | } | ||
pnpm-lock.yaml+26| ... | @@ -1073,6 +1073,9 @@ importers: | ... | @@ -1073,6 +1073,9 @@ importers: |
| 1073 | 1073 | ||
| 1074 | packages/client-runtime: | 1074 | packages/client-runtime: |
| 1075 | dependencies: | 1075 | dependencies: |
| 1076 | '@pierre/diffs': | ||
| 1077 | specifier: 'catalog:' | ||
| 1078 | version: 1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) | ||
| 1076 | '@t3tools/contracts': | 1079 | '@t3tools/contracts': |
| 1077 | specifier: workspace:* | 1080 | specifier: workspace:* |
| 1078 | version: link:../contracts | 1081 | version: link:../contracts |
| ... | @@ -15766,6 +15769,21 @@ snapshots: | ... | @@ -15766,6 +15769,21 @@ snapshots: |
| 15766 | transitivePeerDependencies: | 15769 | transitivePeerDependencies: |
| 15767 | - '@shikijs/themes' | 15770 | - '@shikijs/themes' |
| 15768 | 15771 | ||
| 15772 | '@pierre/diffs@1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': | ||
| 15773 | dependencies: | ||
| 15774 | '@pierre/theme': 2.0.0 | ||
| 15775 | '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) | ||
| 15776 | '@shikijs/transformers': 4.2.0 | ||
| 15777 | diff: 9.0.0 | ||
| 15778 | hast-util-to-html: 9.0.5 | ||
| 15779 | lru_map: 0.4.1 | ||
| 15780 | shiki: 4.2.0 | ||
| 15781 | optionalDependencies: | ||
| 15782 | react: 19.3.0 | ||
| 15783 | react-dom: 19.3.0(react@19.3.0) | ||
| 15784 | transitivePeerDependencies: | ||
| 15785 | - '@shikijs/themes' | ||
| 15786 | |||
| 15769 | '@pierre/theme@1.1.0': | 15787 | '@pierre/theme@1.1.0': |
| 15770 | optional: true | 15788 | optional: true |
| 15771 | 15789 | ||
| ... | @@ -15803,6 +15821,14 @@ snapshots: | ... | @@ -15803,6 +15821,14 @@ snapshots: |
| 15803 | react-dom: 19.2.6(react@19.2.6) | 15821 | react-dom: 19.2.6(react@19.2.6) |
| 15804 | shiki: 4.2.0 | 15822 | shiki: 4.2.0 |
| 15805 | 15823 | ||
| 15824 | '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': | ||
| 15825 | optionalDependencies: | ||
| 15826 | '@pierre/theme': 2.0.0 | ||
| 15827 | '@shikijs/themes': 4.3.0 | ||
| 15828 | react: 19.3.0 | ||
| 15829 | react-dom: 19.3.0(react@19.3.0) | ||
| 15830 | shiki: 4.2.0 | ||
| 15831 | |||
| 15806 | '@pierre/trees@1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': | 15832 | '@pierre/trees@1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': |
| 15807 | dependencies: | 15833 | dependencies: |
| 15808 | '@pierre/theming': 1.0.0(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) | 15834 | '@pierre/theming': 1.0.0(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) |