diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt index ee40275ede84ef6a5030b012850c7ca7c036c3a5..cee0c80146f0a20faa372544bfdd834c8dc630c1 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt @@ -41,6 +41,9 @@ class T3ReviewDiffModule : Module() { Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double -> view.setContentWidth(contentWidth.toFloat()) } + Prop("scrollEnabled") { view: T3ReviewDiffView, enabled: Boolean -> + view.setScrollEnabled(enabled) + } Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double -> view.setInitialRowIndex(initialRowIndex) } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 37fee1cb3613c60230665f944cadc85755392dc7..62df467588682c413557f699cd4bdaee75505215 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -50,12 +50,21 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont private val verticalScroller = OverScroller(context) private val horizontalScroller = OverScroller(context) private var dragAxis: DragAxis? = null + private var scrollEnabled = true private var horizontalDragTarget: HorizontalPanTarget? = null private var lastTouchX = 0f private var lastTouchY = 0f private var velocityTracker: VelocityTracker? = null init { + canvasView.onContentHeightChanged = { height -> + onDebug( + mapOf( + "message" to "metrics", + "contentHeight" to height / resources.displayMetrics.density + ) + ) + } canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) } canvasView.onVisibleRowsChanged = { first, last -> onDebug( @@ -198,6 +207,14 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont scrollToY(canvasView.rowTop(index), animated) } + fun setScrollEnabled(enabled: Boolean) { + scrollEnabled = enabled + if (!enabled) { + verticalScroller.forceFinished(true) + canvasView.setVerticalOffset(0) + } + } + fun scrollToTop(animated: Boolean) { scrollToY(0, animated) } @@ -212,7 +229,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont horizontalDragTarget = canvasView.horizontalPanTarget(event.y) lastTouchX = event.x lastTouchY = event.y - parent?.requestDisallowInterceptTouchEvent(true) + parent?.requestDisallowInterceptTouchEvent(scrollEnabled) return false } MotionEvent.ACTION_MOVE -> { @@ -221,7 +238,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont val deltaY = event.y - lastTouchY if (max(abs(deltaX), abs(deltaY)) > touchSlop) { dragAxis = if (abs(deltaY) >= abs(deltaX)) { - DragAxis.VERTICAL + if (scrollEnabled) DragAxis.VERTICAL else null } else { horizontalDragTarget ?.takeIf { canvasView.maxHorizontalOffset(it) > 0 } @@ -229,6 +246,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont } } } + if (dragAxis == DragAxis.HORIZONTAL) parent?.requestDisallowInterceptTouchEvent(true) return dragAxis != null } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> return dragAxis != null @@ -751,6 +769,7 @@ private class DiffCanvasView(context: Context) : View(context) { } var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null + var onContentHeightChanged: ((Int) -> Unit)? = null fun prepareRows() = drawing.prepareRows(tokensByRowId, style, width) fun useCodeLayouts(layouts: CodeLayoutCache) { @@ -876,6 +895,7 @@ private class DiffCanvasView(context: Context) : View(context) { setVerticalOffset(verticalOffset) setHorizontalOffset(horizontalOffset) clampHeaderPathOffsets() + onContentHeightChanged?.invoke(rowOffsets.last()) } private fun rowHeight(row: DiffRow): Int = when (row.kind) { @@ -1198,14 +1218,7 @@ private class DiffCanvasView(context: Context) : View(context) { } val selected = selectedRowIds.contains(row.id) if (selected) { - fill( - canvas, - withAlpha(theme.hunkText, 56), - 0f, - top.toFloat(), - width.toFloat(), - bottom.toFloat(), - ) + // Keep the diff and syntax colors intact; selection lives in the gutter. fill( canvas, withAlpha(theme.hunkText, 242), diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift index 3ccadc5821c2d9bc4ddae23c0a37c24257244604..0b8bd70633b5a3cd9557b748ac2151445f85fa79 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift @@ -61,6 +61,18 @@ public class T3ReviewDiffModule: Module { view.setInitialRowIndex(initialRowIndex) } + Prop("scrollEnabled") { (view: T3ReviewDiffView, enabled: Bool) in + view.setScrollEnabled(enabled) + } + + Prop("bounces") { (view: T3ReviewDiffView, bounces: Bool) in + view.setBounces(bounces) + } + + Prop("refreshEnabled") { (view: T3ReviewDiffView, enabled: Bool) in + view.setRefreshEnabled(enabled) + } + Prop("refreshing") { (view: T3ReviewDiffView, refreshing: Bool) in view.setRefreshing(refreshing) } diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index d781cc7c0d8573ba9c9d4e95568f0e69c44be1c2..92a9a2f71d46c23059660461504fdbc221fe2e39 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -365,7 +365,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { scrollView.showsHorizontalScrollIndicator = false scrollView.backgroundColor = contentView.theme.background pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged) - scrollView.refreshControl = pullRefreshControl addSubview(scrollView) contentView.backgroundColor = contentView.theme.background @@ -701,14 +700,14 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { applyPendingScrollIfNeeded() emitVisibleFileIfNeeded() - let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))" + let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(contentView.contentHeight))" if debugKey != lastMetricsDebugKey { lastMetricsDebugKey = debugKey emitDebug("metrics", [ "rows": rows.count, "boundsWidth": Double(bounds.width), "boundsHeight": Double(bounds.height), - "contentHeight": Double(height), + "contentHeight": Double(contentView.contentHeight), "contentWidth": Double(style.contentWidth), "fileHeaderHeight": Double(style.fileHeaderHeight), "rowHeight": Double(style.rowHeight), @@ -840,6 +839,20 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { setVerticalContentOffset(0, animated: animated) } + func setScrollEnabled(_ enabled: Bool) { + scrollView.isScrollEnabled = enabled + } + + func setBounces(_ bounces: Bool) { + scrollView.bounces = bounces + scrollView.alwaysBounceVertical = bounces + } + + func setRefreshEnabled(_ enabled: Bool) { + if !enabled { pullRefreshControl.endRefreshing() } + scrollView.refreshControl = enabled ? pullRefreshControl : nil + } + @objc private func handlePullToRefresh() { onPullToRefresh([:]) } @@ -2429,10 +2442,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return } - let selectionColor = theme.hunkText.withAlphaComponent(0.22) - selectionColor.setFill() - context.fill(rect) - + // Keep the diff and syntax colors intact; selection lives in the gutter. theme.hunkText.withAlphaComponent(0.95).setFill() context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height)) } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 1adcf4ee755d8999acd2b56ee75ef5b43880026c..ee5b314d369193bef06d7e78284677d54d0ae5b1 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1,3 +1,4 @@ +import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen"; import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen"; import { PullRequestDiscussionScreen } from "./features/pull-requests/PullRequestDiscussionScreen"; import { @@ -722,6 +723,10 @@ const RootStackConfig = createWorkspaceStackNavigator({ }, options: GLASS_HEADER_OPTIONS, }), + PullRequestGuideStop: createNativeStackScreen({ + screen: PullRequestGuideStopScreen, + options: GLASS_HEADER_OPTIONS, + }), PullRequestFile: createNativeStackScreen({ screen: PullRequestFileRouteScreen, options: GLASS_HEADER_OPTIONS, diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index c950dcef418a2b86e3ff9b8f683d09295301d404..8a5e6c4de8fbe02c30abab5632ac8e9d1e2ca101 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -124,6 +124,9 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly rowHeight: number; readonly contentWidth: number; readonly initialRowIndex?: number; + /** Disable the native vertical scroller when an excerpt grows inside a page. */ + readonly scrollEnabled?: boolean; + readonly bounces?: boolean; readonly refreshing?: boolean; readonly nativeViewRef?: Ref; readonly onPullToRefresh?: (event: NativeSyntheticEvent>) => void; @@ -167,6 +170,7 @@ type NativeReviewDiffRawViewProps = Omit< NativeReviewDiffViewProps, "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson" > & { + readonly refreshEnabled: boolean; readonly ref?: Ref; }; @@ -270,7 +274,11 @@ function NativeReviewDiffView(props: NativeReviewDiffViewProps) { return null; } - return createElement(RawNativeView, { ...nativeProps, ref: nativeRef }); + return createElement(RawNativeView, { + ...nativeProps, + refreshEnabled: props.onPullToRefresh !== undefined, + ref: nativeRef, + }); } export function resolveNativeReviewDiffView(): ComponentType | null { diff --git a/apps/mobile/src/features/pull-requests/PullRequestActivityFilters.tsx b/apps/mobile/src/features/pull-requests/PullRequestActivityFilters.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8d2b6ea06240d0c8fd358ab517f69eb7779fb35d --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestActivityFilters.tsx @@ -0,0 +1,91 @@ +import { Pressable, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { ControlPillMenu } from "../../components/ControlPillMenu"; +import type { ActivityFilter } from "./pullRequestActivityFilter"; + +export function PullRequestActivityFilters({ + value, + onChange, + hiddenCount, +}: { + readonly value: ActivityFilter; + readonly onChange: (value: ActivityFilter) => void; + readonly hiddenCount: number; +}) { + const kinds = [ + { id: "all", title: "All activity" }, + { id: "comments", title: "Comments" }, + { id: "commits", title: "Commits" }, + ] as const; + return ( + + ({ ...kind, state: value.kind === kind.id ? "on" : "off" }))} + onPressAction={({ nativeEvent }) => { + const kind = kinds.find((item) => item.id === nativeEvent.event); + if (kind) onChange({ ...value, kind: kind.id }); + }} + > + kind.id === value.kind)?.title}`} + className="min-h-11 flex-row items-center gap-2 active:opacity-60" + > + + {kinds.find((kind) => kind.id === value.kind)?.title} + + + + + + { + if (event === "bots" || event === "resolved") + onChange({ ...value, [event]: !value[event] }); + }} + > + + {hiddenCount > 0 ? ( + {hiddenCount} hidden + ) : null} + + + + onChange({ ...value, newestFirst: !value.newestFirst })} + className="h-11 flex-row items-center gap-1 active:opacity-60" + > + + + {value.newestFirst ? "Newest" : "Oldest"} + + + + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestControls.tsx index 05691cc88457103f659f0c1c633fa0e11e7b178c..0a7fe6b34f08fbc952600ea7b0a107592abb422b 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestControls.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestControls.tsx @@ -1,3 +1,5 @@ +import Animated from "react-native-reanimated"; +import { PR_ROW_LAYOUT } from "./pullRequestAnimations"; import { useNavigation } from "@react-navigation/native"; import type { PullRequestDetail } from "@t3tools/contracts"; import type { ReactNode } from "react"; @@ -21,9 +23,12 @@ import { usePullRequestAgentActions } from "./usePullRequestAgentActions"; export function PullRequestCardGroup(props: { readonly children: ReactNode }) { return ( - + {props.children} - + ); } diff --git a/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx b/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx index 01ec3e190b03f4f68d2eb1be89feddb0e18ed7ba..ea0ab44b68b868bd6b5186301ce380bc53a5aa7d 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestFiles.tsx @@ -1,4 +1,6 @@ -import { LegendList } from "@legendapp/list/react-native"; +import { AnimatedLegendList } from "@legendapp/list/reanimated"; +import Animated from "react-native-reanimated"; +import { PR_ROW_LAYOUT, PR_ROW_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations"; import { useNavigation } from "@react-navigation/native"; import { useState } from "react"; import { ActivityIndicator, Pressable, View } from "react-native"; @@ -192,7 +194,7 @@ export function usePullRequestFilesContent(props: { ) : parsed.kind === "files" ? ( <> - + {parsed.fileCount} {filter.hiddenCount ? "visible" : "changed"}{" "} @@ -201,11 +203,11 @@ export function usePullRequestFilesContent(props: { ? ` · ${parsed.files.filter((file) => viewed.isViewed(file.path)).length} viewed` : ""} - {filter.hideTestFiles ? ( - - {filter.hiddenCount} test {filter.hiddenCount === 1 ? "file" : "files"} hidden - - ) : null} + + {filter.hideTestFiles + ? `${filter.hiddenCount} test ${filter.hiddenCount === 1 ? "file" : "files"} hidden` + : "Test files included"} + [0]) { const content = usePullRequestFilesContent(props); + const [animationsReady, setAnimationsReady] = useState(false); return ( - row.key} - renderItem={({ item }) => item.content} + itemLayoutAnimation={PR_ROW_LAYOUT} + onLoad={() => setAnimationsReady(true)} + renderItem={({ item }) => ( + + {item.content} + + )} recycleItems={false} estimatedItemSize={64} ListHeaderComponent={content.header} diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index b2a6ba7287a049a899c40ff284ac93e33ec250a0..267b32e07a5cb6bfdf39464ad211fc1aa8412e22 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -1,5 +1,22 @@ -import { PullRequestEntryRow } from "./PullRequestEntryRow"; -import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; +import { pullRequestTabOffset } from "./pullRequestTabScroll"; +import { guideFocusedRows, type GuideDiffFocus } from "./guide/guideDiffFocus"; +import { PullRequestGuideContent } from "./guide/PullRequestGuideScreen"; +import { useReviewGuidesEnabled } from "./guide/useReviewGuide"; +import { + filterPullRequestActivity, + DEFAULT_ACTIVITY_FILTER, + type ActivityFilter, +} from "./pullRequestActivityFilter"; +import { PullRequestActivityFilters } from "./PullRequestActivityFilters"; +import type { LegendListRef } from "@legendapp/list/react-native"; +import { AnimatedLegendList } from "@legendapp/list/reanimated"; +import Animated from "react-native-reanimated"; +import { + PR_ROW_LAYOUT, + PR_ROW_ENTER, + PR_ROW_EXIT, + PR_POSITION_LAYOUT, +} from "./pullRequestAnimations"; import { openOnProviderLabel } from "./pullRequestActions"; import * as Clipboard from "expo-clipboard"; import { ControlPillMenu } from "../../components/ControlPillMenu"; @@ -23,7 +40,7 @@ import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/contracts"; import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading"; import { pullRequestStatus } from "./pullRequestStatus"; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Linking, @@ -104,76 +121,39 @@ function PullRequestTabScrollView(props: ScrollViewProps) { ); } -function usePullRequestActivityRows(params: PullRequestParams, commitsOnly: boolean) { +function usePullRequestActivityRows(params: PullRequestParams, filter: ActivityFilter) { const navigation = useNavigation(); const result = useEnvironmentQuery(composerPullRequests.activity(requestTarget(params))); + const filtered = useMemo( + () => (result.data ? filterPullRequestActivity(result.data, filter) : null), + [result.data, filter], + ); const rows = useMemo(() => { - const rows: { key: string; content: ReactElement }[] = []; - if (commitsOnly) { - for (const commit of result.data?.commits ?? []) - rows.push({ - key: `commit:${commit.oid}`, - content: ( - - navigation.dispatch( - StackActions.push("PullRequestFile", { - ...params, - commit: commit.oid, - path: "", - }), - ) - } - className={`border-b border-border-subtle pb-3 active:opacity-70 ${rows.length ? "pt-3" : ""}`} - > - - - - } - > - - - - {commit.messageHeadline} - - - {commit.oid.slice(0, 7)} - - - - - {commit.authors?.map((author) => author.login).join(", ") || "Commit"} ·{" "} - {relativeTime(commit.committedDate)} - - - - - ), - }); - } else if (result.data) { - rows.push(...pullRequestTimelineRows(result.data, params, navigation)); + if (!filtered) return []; + const rows = pullRequestTimelineRows(filtered.activity, params, navigation); + if (filter.newestFirst) { + const notice = rows.at(-1)?.key === "truncated" ? rows.pop() : undefined; + rows.reverse(); + if (notice) rows.push(notice); } - if (!rows.length && result.data) + if (!rows.length) rows.push({ key: "empty", + date: "", content: ( - {commitsOnly ? "No commits reported." : "No comments yet."} + No activity matches these filters. ), }); return rows; - }, [commitsOnly, navigation, params, result.data]); - return { rows, pending: result.isPending, error: result.error }; + }, [filtered, filter.newestFirst, params, navigation]); + return { + rows, + hiddenCount: filtered?.hiddenCount ?? 0, + pending: result.isPending, + error: result.error, + }; } function PullRequestIdentity(props: { @@ -257,26 +237,33 @@ function PullRequestOverview(props: { - - + + + + + + {pr.labels.length ? ( - + {pr.labels.map((label) => ( {label.name} ))} - + ) : null} - props.onDescriptionLayout(event.nativeEvent.layout.y)}> + props.onDescriptionLayout(event.nativeEvent.layout.y)} + > - + ); } @@ -285,43 +272,45 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps("overview"); - // LegendList commits replacement rows after the header. Keep selection with the visible content. - const [displayedSection, setDisplayedSection] = useState(section); + const [section, setSection] = useState<"overview" | "activity" | "guide">("overview"); + // Keep each tab's native list and measured rows alive when it is hidden. const requestedSection = useRef(section); + const [listAnimationsReady, setListAnimationsReady] = useState(false); const [descriptionExpanded, setDescriptionExpanded] = useState(false); const filesContent = usePullRequestFilesContent({ params }); - const activityContent = usePullRequestActivityRows(params, false); - const commitsContent = usePullRequestActivityRows(params, true); + const guidesEnabled = useReviewGuidesEnabled(params); + const [activityFilter, setActivityFilter] = useState(DEFAULT_ACTIVITY_FILTER); + const activityContent = usePullRequestActivityRows(params, activityFilter); const scrollOffsets = useRef>({}); const currentScrollOffset = useRef(-insets.top); const bodyY = useRef(0); - const pendingScrollOffset = useRef(null); - const selectSection = (next: "overview" | "activity" | "commits") => { + const [headerHeight, setHeaderHeight] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); + const scrollViews = useRef>>({}); + const selectSection = (next: typeof section) => { if (next === section) return; - requestedSection.current = next; scrollOffsets.current[section] = currentScrollOffset.current; - if (scrollOffsets.current[next] === undefined) { - scrollOffsets.current[next] = Math.min( - currentScrollOffset.current, - bodyY.current - insets.top, - ); - } - pendingScrollOffset.current = scrollOffsets.current[next] ?? -insets.top; + const headerEnd = Math.max(-insets.top, bodyY.current - insets.top); + // The shared header stays in place. Once collapsed, retain each tab's own reading position. + const offset = pullRequestTabOffset( + currentScrollOffset.current, + scrollOffsets.current[next], + headerEnd, + ); + requestedSection.current = next; + scrollOffsets.current[next] = offset; + currentScrollOffset.current = offset; + scrollViews.current[next]?.scrollToOffset({ offset, animated: false }); setSection(next); }; - const tab = section; - const tabContent = section === "commits" ? commitsContent : activityContent; - const scrollView = useRef(null); - const commitSectionLayout = () => { - if (requestedSection.current !== section) return; - setDisplayedSection(section); - const offset = pendingScrollOffset.current; - if (offset !== null) { - pendingScrollOffset.current = null; - scrollView.current?.scrollToOffset({ offset, animated: false }); + useEffect(() => { + if (!guidesEnabled && section === "guide") { + requestedSection.current = "overview"; + currentScrollOffset.current = scrollOffsets.current.overview ?? -insets.top; + setSection("overview"); } - }; + }, [guidesEnabled, section, insets.top]); + const tabContent = activityContent; const filesY = useRef(0); const descriptionY = useRef(0); const pendingDescriptionJump = useRef(false); @@ -329,7 +318,6 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps refreshDetail(), [refreshDetail])); const pr = result.data; @@ -338,24 +326,27 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps { pullRefresh.onRefresh(); + if (guidesEnabled) refreshGuide(); stack.refresh(); refreshActivity(); filesContent.refresh(); }; - const jumpToFiles = useCallback(() => { - scrollView.current?.scrollToOffset({ + const jumpToFiles = () => { + scrollViews.current.overview?.scrollToOffset({ offset: bodyY.current + filesY.current - insets.top, animated: true, }); pendingFilesJump.current = false; - }, [insets.top]); + }; const openFiles = () => { if (section === "overview") jumpToFiles(); else { pendingFilesJump.current = true; selectSection("overview"); + if (filesY.current > 0) requestAnimationFrame(jumpToFiles); } }; return ( @@ -443,155 +434,207 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps - - { - currentScrollOffset.current = event.nativeEvent.contentOffset.y; - }} - className="flex-1" - renderScrollComponent={Platform.OS === "ios" ? PullRequestTabScrollView : undefined} - contentInsetAdjustmentBehavior="never" - contentInset={ - Platform.OS === "ios" ? { top: insets.top, bottom: insets.bottom } : undefined - } - contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} - refreshControl={ - - } - alwaysBounceVertical - scrollsToTop - onContentSizeChange={() => { - if (tab !== "overview" || !pendingDescriptionJump.current) return; - pendingDescriptionJump.current = false; - // Collapse changes the scroll range; restore the heading after that layout commits. - scrollView.current?.scrollToOffset({ - offset: bodyY.current + descriptionY.current - insets.top, - animated: false, - }); - }} - contentContainerStyle={{ - paddingHorizontal: 16, - paddingTop: 8, - paddingBottom: 24, - }} - data={[ - { - key: `${tab}:body`, - content: ( - - {tab === "overview" && identity ? ( - - {pr ? ( - { - descriptionY.current = y; - }} - onDescriptionCollapse={() => { - pendingDescriptionJump.current = true; - }} - /> - ) : summary ? ( - - ) : null} + setViewportHeight(event.nativeEvent.layout.height)} + > + {(["overview", "guide", "activity"] as const) + .filter((tab) => tab !== "guide" || guidesEnabled) + .map((tab) => ( + + { + scrollViews.current[tab] = list; + }} + initialScrollOffset={-insets.top} + onScroll={(event) => { + const offset = event.nativeEvent.contentOffset.y; + scrollOffsets.current[tab] = offset; + if (requestedSection.current === tab) currentScrollOffset.current = offset; + }} + className="flex-1" + renderScrollComponent={ + Platform.OS === "ios" ? PullRequestTabScrollView : undefined + } + contentInsetAdjustmentBehavior="never" + contentInset={ + Platform.OS === "ios" ? { top: insets.top, bottom: insets.bottom } : undefined + } + contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} + refreshControl={ + + } + alwaysBounceVertical + scrollsToTop={section === tab} + onContentSizeChange={() => { + if ( + tab !== "overview" || + section !== "overview" || + !pendingDescriptionJump.current + ) + return; + pendingDescriptionJump.current = false; + // Collapse changes the scroll range; restore the heading after that layout commits. + scrollViews.current.overview?.scrollToOffset({ + offset: bodyY.current + descriptionY.current - insets.top, + animated: false, + }); + }} + contentContainerStyle={{ + paddingHorizontal: 16, + paddingTop: 8, + paddingBottom: 24, + // Short tabs must still be tall enough to preserve the collapsed header. + minHeight: Math.max( + 0, + viewportHeight + headerHeight - insets.top - insets.bottom, + ), + }} + data={[ + { + key: `${tab}:body`, + content: ( + + {tab === "overview" && identity ? ( + + {pr ? ( + { + descriptionY.current = y; + }} + onDescriptionCollapse={() => { + pendingDescriptionJump.current = true; + }} + /> + ) : summary ? ( + + ) : null} + { + filesY.current = event.nativeEvent.layout.y; + if (pendingFilesJump.current) { + requestAnimationFrame(() => { + if (requestedSection.current === "overview") jumpToFiles(); + }); + } + }} + > + {filesContent.header} + + + ) : null} + {tab === "guide" ? : null} + {tab === "activity" ? ( + + ) : null} + {tab === "activity" ? ( + + ) : null} + + ), + }, + ...(tab === "overview" + ? identity + ? filesContent.rows + : [] + : tab === "activity" + ? tabContent.rows + : [] + ).map((row) => ({ + ...row, + key: `${tab}:${row.key}`, + content: row.content, + })), + ]} + keyExtractor={(row) => row.key} + getItemType={(row) => + row.key.endsWith(":body") ? row.key : row.key.split(":")[0] + } + // The overview body contains the clipped description and owns collapse scroll + // correction. Animating that cell fights both; file rows still fade individually. + itemLayoutAnimation={tab === "overview" ? undefined : PR_ROW_LAYOUT} + onLoad={() => setListAnimationsReady(true)} + renderItem={({ item }) => ( + + {item.content} + + )} + recycleItems={false} + estimatedItemSize={tab === "overview" ? 64 : 220} + maintainVisibleContentPosition={false} + ListHeaderComponent={ + <> + + {identity ? ( { - filesY.current = event.nativeEvent.layout.y; - if (pendingFilesJump.current) { - // The explicit file jump supersedes the tab's restored offset. - pendingScrollOffset.current = null; - requestAnimationFrame(() => { - if (requestedSection.current === "overview") jumpToFiles(); - }); - } + bodyY.current = event.nativeEvent.layout.height; + setHeaderHeight(event.nativeEvent.layout.height); }} > - {filesContent.header} + + - - ) : null} - {tab !== "overview" ? ( - - ) : null} - - ), - }, - ...(tab === "overview" ? (identity ? filesContent.rows : []) : tabContent.rows).map( - (row) => ({ - ...row, - key: `${tab}:${row.key}`, - content: - tab !== "overview" ? ( - {row.content} - ) : ( - row.content - ), - }), - ), - ]} - keyExtractor={(row) => row.key} - getItemType={(row) => (row.key.endsWith(":body") ? row.key : row.key.split(":")[0])} - renderItem={({ item }) => item.content} - recycleItems={false} - estimatedItemSize={tab === "overview" ? 64 : tab === "commits" ? 80 : 220} - maintainVisibleContentPosition={false} - ListHeaderComponent={ - <> - + } + ListFooterComponent={tab === "overview" ? filesContent.footer : null} /> - {identity ? ( - { - bodyY.current = event.nativeEvent.layout.height; - }} - > - - - - ) : null} - - } - ListFooterComponent={tab === "overview" ? filesContent.footer : null} - /> + + ))} @@ -632,13 +675,17 @@ export function PullRequestFileRouteScreen({ readonly path: string; readonly fileSelectionId?: string; readonly replacingDraftId?: string; + readonly focus?: GuideDiffFocus; } >) { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const commit = route.params.commit; const result = usePullRequestDiff(route.params); - const filter = usePullRequestFileFilter(result.parsed); + const filter = usePullRequestFileFilter( + result.parsed, + route.params.focus ? route.params.path : undefined, + ); const detail = useEnvironmentQuery(composerPullRequests.detail(requestTarget(route.params))); const { refresh: refreshDetail } = detail; useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail])); @@ -709,6 +756,11 @@ export function PullRequestFileRouteScreen({ const targetFile = data.files.find((file) => file.path === jumpPath) ?? (!jumpPath || requestedFile ? data.files[0] : undefined); + const focus = route.params.focus; + const focusedRows = guideFocusedRows(data.rows, targetFile?.id, focus); + const focusIndex = focusedRows.length + ? data.rows.findIndex((row) => row.id === focusedRows[0]?.id) + : -1; const [collapsedIds, setCollapsedIds] = useState(EMPTY_IDS); const [visibleFileId, setVisibleFileId] = useState(null); const nativeView = useRef(null); @@ -722,6 +774,7 @@ export function PullRequestFileRouteScreen({ ) return; lastSelection.current = { path: route.params.path, id: route.params.fileSelectionId }; + if (route.params.focus) navigation.setParams({ focus: undefined }); setJumpPath(route.params.path); setSelectedRowId(null); jumpStarted.current = false; @@ -732,7 +785,7 @@ export function PullRequestFileRouteScreen({ void nativeView.current.scrollToFile(file.id, false).catch(() => { jumpStarted.current = false; }); - }, [route.params.path, route.params.fileSelectionId, data.files]); + }, [route.params.path, route.params.fileSelectionId, route.params.focus, data.files, navigation]); const appliedFilter = useRef(filter.hideTestFiles); useEffect(() => { if (appliedFilter.current === filter.hideTestFiles) return; @@ -745,7 +798,7 @@ export function PullRequestFileRouteScreen({ } }, [filter.hideTestFiles, result.parsed]); const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); - const { themeAppearance } = useAppearancePreferences(); + const { themeAppearance, appearance, setCodeWordBreak } = useAppearancePreferences(); const bridge = useNativeReviewDiffBridge({ threadKey: null, sectionId: `pull-request:${route.params.repository}:${route.params.number}:${commit ?? "all"}`, @@ -753,7 +806,7 @@ export function PullRequestFileRouteScreen({ data, collapsedFileIds: collapsedIds, viewedFileIds: viewedIds, - selectedRowIds: selectedRowId ? [selectedRowId] : EMPTY_IDS, + selectedRowIds: selectedRowId ? [selectedRowId] : focusedRows.map((row) => row.id), canHighlight: true, }); const [NativeDiffView] = useState(() => resolveNativeReviewDiffView()); @@ -783,6 +836,7 @@ export function PullRequestFileRouteScreen({ (Platform.OS !== "ios" || decoded) ) { jumpStarted.current = true; + if (focusIndex >= 0) return; void nativeView.current.scrollToFile(targetFile.id, false).catch(() => { jumpStarted.current = false; }); @@ -792,7 +846,7 @@ export function PullRequestFileRouteScreen({ const lastRow = event.nativeEvent.lastRowIndex; if (typeof lastRow === "number" && lastRow >= data.rows.length - 40) loadMore(); }, - [updateVisibleRange, data.rows.length, loadMore, targetFile], + [updateVisibleRange, data.rows.length, loadMore, targetFile, focusIndex], ); const onToggleFile = useCallback>( (event) => { @@ -829,6 +883,12 @@ export function PullRequestFileRouteScreen({ trailing={ { - if (nativeEvent.event === "hide-tests") toggleTestFiles(); + if (nativeEvent.event === "word-wrap") setCodeWordBreak(!appearance.codeWordBreak); + else if (nativeEvent.event === "hide-tests") toggleTestFiles(); else if ( nativeEvent.event === "submit-review" || nativeEvent.event === "comment-on-pr" @@ -895,6 +956,13 @@ export function PullRequestFileRouteScreen({ Add comment ) : null} + setCodeWordBreak(!appearance.codeWordBreak)} + > + Word wrap + = 0 ? Math.max(0, focusIndex - 3) : undefined} nativeViewRef={nativeView} refreshing={result.refreshing} onPullToRefresh={result.onPullRefresh} diff --git a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx index c7bdda259e5aa7487b02e11b3880fbe6661909c6..c331b6b7d15f566e011a344b11936767cead6ec0 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx @@ -1,6 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; import { useNavigation } from "@react-navigation/native"; import type { PullRequestAction, PullRequestDetail } from "@t3tools/contracts"; +import Animated from "react-native-reanimated"; +import { PR_ROW_LAYOUT, PR_DISCLOSURE_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations"; import { useState } from "react"; import { Linking, Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; @@ -97,7 +99,7 @@ export function PullRequestChecksRow(props: RowProps) { const checks = pullRequestChecksSummary(props.pr); const action = usePullRequestAction(props.params, props.pr); return ( - <> + setExpanded(!expanded)} /> {expanded ? ( - + {props.pr.state === "open" && props.pr.checks.some((check) => check.status === "failure") ? ( @@ -157,9 +164,9 @@ export function PullRequestChecksRow(props: RowProps) { ) } /> - + ) : null} - + ); } @@ -236,7 +243,7 @@ export function PullRequestReviewersRow(props: RowProps) { props.pr.capabilities.reviewers.request && props.pr.viewerPermissions.requestReviewers; return ( - <> + setExpanded(!expanded)} /> {expanded ? ( - + {summary.reviews.map((review) => ( void Linking.openURL(props.pr.url)} /> )} - + ) : null} - + ); } diff --git a/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx b/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx new file mode 100644 index 0000000000000000000000000000000000000000..08bf79db5cb9f97c5152e530d4eb7bc56ba97495 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx @@ -0,0 +1,115 @@ +import type { PullRequestReviewGuide } from "@t3tools/contracts"; +import { stopFilePaths } from "@t3tools/client-runtime/review-guide-tour"; +import { Pressable, View } from "react-native"; +import { AppText as Text } from "../../../components/AppText"; +import { SymbolView } from "../../../components/AppSymbol"; +import { PullRequestMarkdown } from "../PullRequestMarkdown"; +import { ControlPillMenu } from "../../../components/ControlPillMenu"; + +import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; + +/** The stops are a reading sequence, with file context kept beside each idea. */ +export function GuideOutline({ + guide, + onOpen, + onRewrite, +}: { + readonly guide: PullRequestReviewGuide; + readonly onOpen: (position: number) => void; + readonly onRewrite?: () => void; +}) { + const { appearance, setCodeWordBreak } = useAppearancePreferences(); + const files = new Set(guide.stops.flatMap(stopFilePaths)); + return ( + + + + {guide.stops.length} stops + + through {files.size} {files.size === 1 ? "file" : "files"} + + + + nativeEvent.event === "word-wrap" + ? setCodeWordBreak(!appearance.codeWordBreak) + : onRewrite?.() + } + > + + + + + + {guide.stops[0]?.summary ? ( + + + + ) : null} + {guide.stops.map((stop, position) => { + const paths = stopFilePaths(stop); + const firstPath = paths[0]; + const filename = firstPath?.split("/").at(-1); + return ( + onOpen(position)} + className="flex-row gap-4 active:opacity-60" + > + + + {position + 1} + + {position < guide.stops.length - 1 ? ( + + ) : null} + + + {stop.title} + + {stop.attention ? ( + + ) : null} + + {filename ?? "Context"} + {paths.length > 1 ? ` +${paths.length - 1}` : ""} + + + + + + + + ); + })} + {guide.stops.length ? ( + onOpen(0)} + className="min-h-11 flex-row items-center justify-end gap-2 active:opacity-60" + > + Start tour + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1bf9fc02c2d815fb86a15f195893e2e2e6910bfb --- /dev/null +++ b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx @@ -0,0 +1,400 @@ +import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; +import type { FileDiffMetadata } from "@pierre/diffs"; +import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { useEffect, useMemo } from "react"; +import { Platform, Pressable, View } from "react-native"; +import { AndroidScreenHeader } from "../../../components/AndroidScreenHeader"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + letterStopNotes, + stepTourPosition, + stopFilePaths, +} from "@t3tools/client-runtime/review-guide-tour"; +import { SymbolView } from "../../../components/AppSymbol"; +import { GuideOutline } from "./GuideOutline"; +import { AppText as Text } from "../../../components/AppText"; +import { MaterialButton } from "../../../components/MaterialButton"; +import { ScreenScrollView } from "../../../components/ScreenScrollView"; +import { ControlPillMenu } from "../../../components/ControlPillMenu"; +import { MaterialIconButton } from "../../../components/MaterialIconButton"; +import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../../native/StackHeader"; +import { PullRequestCardGroup, PullRequestCardRow } from "../PullRequestControls"; +import { PullRequestMarkdown, PullRequestMarkdownRepository } from "../PullRequestMarkdown"; +import { usePullRequestDiff, type PullRequestParams } from "../usePullRequestDiff"; +import { ReviewGuideExcerpt } from "./ReviewGuideExcerpt"; +import { reviewGuideState } from "./reviewGuideState"; +import { useEnvironmentQuery } from "../../../state/query"; +import { composerPullRequests } from "../../../state/pull-requests"; +import { usePullRequestPermissions } from "../usePullRequestPermissions"; +import { pullRequestTarget } from "../usePullRequestDiff"; +import { useReviewGuide } from "./useReviewGuide"; + +export type GuideStopParams = PullRequestParams & { + readonly position: number; + readonly guideHeadSha: string; + readonly guideGeneratedAt: string; +}; + +const attentionLabels = { + tradeoff: "A tradeoff to weigh", + contract: "A contract to check", + product: "A product call", + risk: "A risk to judge", + accessibility: "An accessibility call", +}; + +function GuideNotice({ state }: { readonly state: ReturnType }) { + const { guide, stale, failure } = reviewGuideState(state.data); + const error = state.startError ?? failure ?? state.error; + const label = state.running + ? guide + ? "Rewriting review guide" + : "Writing review guide" + : error + ? "Could not write guide" + : stale + ? "Guide is out of date" + : "No review guide yet"; + if (guide && !stale && !error && !state.running) return null; + return ( + + + + + {label} + + + + {error ?? + (state.running + ? "An agent is reading the change. You can leave this screen and return when it’s ready." + : stale + ? "There are new changes since this guide was written. Rewrite it to see the latest code." + : "Walk through the important code in order, with notes on what changed and where to look closely.")} + + {state.running ? ( + + {state.data?.run?._tag === "Running" + ? `Started at ${new Date(state.data.run.startedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` + : "Starting the guide…"} + + ) : state.canWrite ? ( + void state.write()} + /> + ) : null} + + ); +} + +export function PullRequestGuideContent({ params }: { readonly params: PullRequestParams }) { + const navigation = useNavigation(); + const state = useReviewGuide(params); + const { guide, stale } = reviewGuideState(state.data); + return ( + + {!state.enabled ? ( + Review guides are disabled for this server. + ) : ( + <> + {state.isPending && !state.data ? ( + Loading review guide… + ) : ( + + )} + {guide ? ( + + navigation.dispatch( + StackActions.push("PullRequestGuideStop", { + ...params, + position, + guideHeadSha: guide.headSha, + guideGeneratedAt: guide.generatedAt, + }), + ) + } + onRewrite={ + state.canWrite && !state.running && !stale && !state.startError && !state.error + ? () => void state.write() + : undefined + } + /> + ) : null} + + )} + + ); +} + +export function PullRequestGuideStopScreen({ route }: StaticScreenProps) { + const navigation = useNavigation(); + const state = useReviewGuide(route.params); + const detail = useEnvironmentQuery(composerPullRequests.detail(pullRequestTarget(route.params))); + const permissions = usePullRequestPermissions(route.params, detail.data); + const canReview = + permissions.verdicts.length > 0 && detail.data?.headSha === route.params.guideHeadSha; + const { guide, stale } = reviewGuideState(state.data); + const position = Math.max(0, Math.min(route.params.position, (guide?.stops.length ?? 1) - 1)); + const stop = guide?.stops[Math.max(0, position)]; + const diff = usePullRequestDiff(route.params); + const excerptFiles = useMemo(() => { + const files = new Map(); + try { + for (const patch of parsePatchFiles(diff.patch)) { + for (const file of patch.files) { + files.set(file.name, file); + if (file.prevName) files.set(file.prevName, file); + } + } + } catch { + // An omitted or unparseable patch is shown as unavailable by the excerpt. + } + return files; + }, [diff.patch]); + const wanted = useMemo(() => (stop ? stopFilePaths(stop) : []), [stop]); + const loadedPaths = new Set( + diff.parsed.kind === "files" ? diff.parsed.files.map((file) => file.path) : [], + ); + const missing = wanted.some((path) => !loadedPaths.has(path)); + const { error: diffError, loadMore } = diff; + useEffect(() => { + if (state.enabled && missing && !diffError) loadMore(); + }, [state.enabled, missing, diffError, loadMore]); + const insets = useSafeAreaInsets(); + const changedGuide = + guide !== null && + (guide.headSha !== route.params.guideHeadSha || + guide.generatedAt !== route.params.guideGeneratedAt); + const { appearance, setCodeWordBreak } = useAppearancePreferences(); + const notes = stop ? letterStopNotes(stop).lettered : []; + return ( + + + {Platform.OS === "android" ? ( + navigation.goBack()} + trailing={ + setCodeWordBreak(!appearance.codeWordBreak)} + > + + + + + } + /> + ) : null} + + + setCodeWordBreak(!appearance.codeWordBreak)} + > + Word wrap + + + + + + + {!state.enabled ? ( + Review guides are disabled for this server. + ) : changedGuide ? ( + This guide was rewritten. Go back to start the new tour. + ) : !stop ? ( + {state.error ?? "Loading stop…"} + ) : ( + <> + + {stop.title} + + {stop.attention ? ( + + + + + {attentionLabels[stop.attention.kind]} + + + + + ) : null} + {stale ? ( + + Excerpts are hidden until the guide is rewritten for the current head. + + ) : ( + stop.excerpts.map((excerpt) => ( + + navigation.dispatch( + StackActions.push("PullRequestFile", { + ...route.params, + path: excerpt.path, + focus: { side: excerpt.side, start: excerpt.start, end: excerpt.end }, + fileSelectionId: `guide:${position}:${excerpt.path}:${excerpt.start}`, + }), + ) + } + excerpt={excerpt} + file={excerptFiles.get(excerpt.path)} + loading={diff.isPending || (missing && diff.nextCursor !== null)} + /> + )) + )} + {diff.error ? {diff.error} : null} + {notes.length ? ( + + Notes on this stop + {notes.map(({ marker, note }) => ( + + + {marker} + + + {note.title} + + {note.path.split("/").at(-1)}:{note.line} + + + + + ))} + + ) : null} + {stop.files.length ? ( + + {stop.files.map((file, index) => ( + + navigation.navigate("PullRequestFile", { + ...route.params, + path: file.path, + }) + } + /> + ))} + + ) : null} + {stop.questions.length ? ( + + Questions to consider + {stop.questions.map((question) => ( + + ))} + + ) : null} + + guide && position < guide.stops.length - 1 + ? navigation.dispatch( + StackActions.push("PullRequestGuideStop", { + ...route.params, + position: stepTourPosition(position, 1, guide.stops.length), + }), + ) + : canReview + ? navigation.navigate("PullRequestReview", { + ...route.params, + mode: "review", + }) + : navigation.dispatch(StackActions.popTo("PullRequest", route.params)) + } + > + + + {guide && position < guide.stops.length - 1 + ? `Next · ${position + 2} of ${guide.stops.length}` + : "End of guide"} + + + {guide && position < guide.stops.length - 1 + ? guide.stops[position + 1]?.title + : canReview + ? "Finish review" + : "Finish tour"} + + + + + + + + )} + + + + + ); +} diff --git a/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx b/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f6cd088366563a0e7aa8263fdbc8a7f92a94e9b8 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/guide/ReviewGuideExcerpt.tsx @@ -0,0 +1,135 @@ +import { useMemo, useState } from "react"; +import { Pressable, View } from "react-native"; +import type { FileDiffMetadata } from "@pierre/diffs"; +import type { PullRequestReviewGuideExcerpt } from "@t3tools/contracts"; +import { excerptRows, fileDiffRows, rowsToPatch } from "@t3tools/client-runtime/review-guide-tour"; +import { AppText as Text } from "../../../components/AppText"; +import { SymbolView } from "../../../components/AppSymbol"; +import { resolveNativeReviewDiffView } from "../../diffs/nativeReviewDiffSurface"; +import { buildNativeReviewDiffData } from "../../review/nativeReviewDiffAdapter"; +import { buildReviewParsedDiff } from "../../review/reviewModel"; +import { useNativeReviewDiffBridge } from "../../review/useNativeReviewDiffBridge"; +import { useAppearanceCodeSurface } from "../../settings/appearance/useAppearanceCodeSurface"; +import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; +import { PullRequestMarkdown } from "../PullRequestMarkdown"; + +const EMPTY_IDS: readonly string[] = []; + +export function ReviewGuideExcerpt({ + excerpt, + file, + loading, + onOpen, +}: { + readonly excerpt: PullRequestReviewGuideExcerpt; + readonly file: FileDiffMetadata | undefined; + readonly loading: boolean; + readonly onOpen: () => void; +}) { + const { nativeReviewDiffStyle, codeWordBreak } = useAppearanceCodeSurface(); + const { themeAppearance } = useAppearancePreferences(); + const slice = useMemo(() => { + try { + if (!file) return ""; + return rowsToPatch( + excerpt.path, + file.prevName ?? excerpt.path, + excerptRows(fileDiffRows(file), excerpt), + ); + } catch { + return ""; + } + }, [file, excerpt]); + const data = useMemo(() => { + const parsed = buildNativeReviewDiffData( + buildReviewParsedDiff(slice, `guide:${excerpt.path}:${excerpt.start}`), + ); + // The compact header and real line numbers provide context without file/hunk chrome. + return { + ...parsed, + rows: parsed.rows.filter((row) => row.kind !== "file" && row.kind !== "hunk"), + }; + }, [slice, excerpt.path, excerpt.start]); + const bridge = useNativeReviewDiffBridge({ + threadKey: null, + sectionId: `guide:${excerpt.path}:${excerpt.start}`, + diff: slice, + data, + collapsedFileIds: EMPTY_IDS, + viewedFileIds: EMPTY_IDS, + selectedRowIds: EMPTY_IDS, + canHighlight: true, + }); + const [measuredHeight, setMeasuredHeight] = useState<{ key: string; height: number } | null>( + null, + ); + const measurementKey = `${slice}:${codeWordBreak}:${nativeReviewDiffStyle.rowHeight}`; + const [NativeDiff] = useState(() => resolveNativeReviewDiffView()); + return ( + + {excerpt.caption.trim() ? : null} + + + + {excerpt.path} + + + {excerpt.start}–{excerpt.end} + + + + {NativeDiff && data.rows.length ? ( + + { + bridge.onDebug(event); + const { message, contentHeight } = event.nativeEvent; + if (message === "metrics" && typeof contentHeight === "number") { + setMeasuredHeight((current) => + current?.key === measurementKey && current.height === contentHeight + ? current + : { key: measurementKey, height: contentHeight }, + ); + } + }} + onPressLine={onOpen} + /> + + ) : ( + + {loading + ? `Loading ${excerpt.path}…` + : `${excerpt.path}:${excerpt.start}–${excerpt.end} is not in the loaded diff.`} + + )} + + + ); +} diff --git a/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.test.ts b/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2c7ed11709c27cc23f4840469882c8bc692f3fe --- /dev/null +++ b/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; +import { guideFocusedRows } from "./guideDiffFocus"; + +describe("guide diff focus", () => { + const rows = [ + { id: "old", kind: "line" as const, fileId: "a", oldLineNumber: 10, change: "delete" as const }, + { id: "new", kind: "line" as const, fileId: "a", newLineNumber: 20, change: "add" as const }, + { id: "context", kind: "line" as const, fileId: "a", oldLineNumber: 11, newLineNumber: 21 }, + { id: "other", kind: "line" as const, fileId: "b", newLineNumber: 20 }, + ]; + it("uses the requested side and includes both range boundaries only in the target file", () => { + expect( + guideFocusedRows(rows, "a", { side: "old", start: 10, end: 11 }).map((r) => r.id), + ).toEqual(["old", "context"]); + expect( + guideFocusedRows(rows, "a", { side: "new", start: 20, end: 21 }).map((r) => r.id), + ).toEqual(["new", "context"]); + }); + it("does not select unrelated rows while a later file is loading", () => { + expect(guideFocusedRows(rows, undefined, { side: "new", start: 20, end: 21 })).toEqual([]); + expect(guideFocusedRows(rows, "a", undefined)).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.ts b/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb553cb3d6b4ae0e8d8855d891acd6803dd6bac2 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/guide/guideDiffFocus.ts @@ -0,0 +1,26 @@ +import type { NativeReviewDiffRow } from "../../diffs/nativeReviewDiffSurface"; + +export type GuideDiffFocus = { + readonly side: "old" | "new"; + readonly start: number; + readonly end: number; +}; + +/** Match original diff coordinates, including deletions and renamed files. */ +export function guideFocusedRows( + rows: readonly NativeReviewDiffRow[], + fileId: string | undefined, + focus: GuideDiffFocus | undefined, +) { + if (!fileId || !focus) return []; + return rows.filter((row) => { + const line = focus.side === "old" ? row.oldLineNumber : row.newLineNumber; + return ( + row.fileId === fileId && + row.kind === "line" && + line != null && + line >= focus.start && + line <= focus.end + ); + }); +} diff --git a/apps/mobile/src/features/pull-requests/guide/reviewGuideState.test.ts b/apps/mobile/src/features/pull-requests/guide/reviewGuideState.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..857a2bd3f2fdb7a0b045246327eebb9837a9ba70 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/guide/reviewGuideState.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + IsoDateTime, + ProviderInstanceId, + type PullRequestReviewGuideResult, +} from "@t3tools/contracts"; +import { reviewGuideState } from "./reviewGuideState"; + +const guide: NonNullable = { + headSha: "old-head", + generatedAt: IsoDateTime.make("2026-10-11T00:00:00.000Z"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + stops: [], +}; + +describe("mobile review guide state", () => { + it("keeps a previous guide readable during a rewrite", () => { + const state = reviewGuideState({ + guide, + currentHeadSha: "new-head", + run: { _tag: "Running", headSha: "new-head", startedAt: guide.generatedAt }, + }); + expect(state).toMatchObject({ guide, stale: true, running: true, failure: null }); + }); + it("does not label an unknown head stale", () => { + expect(reviewGuideState({ guide, currentHeadSha: null, run: null }).stale).toBe(false); + }); + it("shows only failures for the current head", () => { + const run = { + _tag: "Failed" as const, + headSha: "old-head", + failedAt: guide.generatedAt, + detail: "The writer disconnected.", + }; + expect(reviewGuideState({ guide: null, currentHeadSha: "old-head", run }).failure).toBe( + run.detail, + ); + expect(reviewGuideState({ guide: null, currentHeadSha: "new-head", run }).failure).toBeNull(); + }); + it("starts empty without claiming a job is running", () => { + expect(reviewGuideState(null)).toEqual({ + guide: null, + stale: false, + running: false, + failure: null, + }); + }); +}); diff --git a/apps/mobile/src/features/pull-requests/guide/reviewGuideState.ts b/apps/mobile/src/features/pull-requests/guide/reviewGuideState.ts new file mode 100644 index 0000000000000000000000000000000000000000..da8bf7315e968c35ca32fc6f46d2e4247fe51363 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/guide/reviewGuideState.ts @@ -0,0 +1,15 @@ +import type { PullRequestReviewGuideResult } from "@t3tools/contracts"; + +/** Old failures must not follow a PR onto a different head. */ +export function reviewGuideState(data: PullRequestReviewGuideResult | null) { + const guide = data?.guide ?? null; + return { + guide, + running: data?.run?._tag === "Running", + stale: guide !== null && data?.currentHeadSha != null && guide.headSha !== data.currentHeadSha, + failure: + data?.run?._tag === "Failed" && data.run.headSha === data.currentHeadSha + ? data.run.detail + : null, + }; +} diff --git a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6850109c4cdf3ed1948285009a195e3579f93d4 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts @@ -0,0 +1,61 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useFocusEffect } from "@react-navigation/native"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { useCallback, useRef, useState } from "react"; +import { composerPullRequests } from "../../../state/pull-requests"; +import { useEnvironmentQuery } from "../../../state/query"; +import { serverEnvironment } from "../../../state/server"; +import { useAtomCommand } from "../../../state/use-atom-command"; +import { pullRequestTarget, type PullRequestParams } from "../usePullRequestDiff"; + +export function useReviewGuidesEnabled(params: PullRequestParams) { + const settings = useAtomValue( + serverEnvironment.settingsValueAtom(pullRequestTarget(params).environmentId), + ); + return settings?.reviewGuidesEnabled === true; +} + +export function useReviewGuide(params: PullRequestParams) { + const target = pullRequestTarget(params); + const enabled = useReviewGuidesEnabled(params); + const query = useEnvironmentQuery(enabled ? composerPullRequests.reviewGuide(target) : null); + const canWrite = useAtomValue( + composerPullRequests.generateReviewGuide.permissionAtom(target.environmentId), + ); + const generate = useAtomCommand(composerPullRequests.generateReviewGuide, { + reportFailure: false, + }); + const [starting, setStarting] = useState(false); + const [error, setError] = useState(null); + const pending = useRef(false); + const running = starting || query.data?.run?._tag === "Running"; + const refresh = query.refresh; + useFocusEffect( + useCallback(() => { + if (!enabled) return; + refresh(); + if (!running) return; + const timer = setInterval(refresh, 5_000); + return () => clearInterval(timer); + }, [enabled, refresh, running]), + ); + const write = async () => { + if (!enabled || !canWrite || running || pending.current) return; + pending.current = true; + setStarting(true); + setError(null); + try { + const result = await generate(target); + if (result._tag === "Failure") { + const cause = squashAtomCommandFailure(result); + setError(cause instanceof Error ? cause.message : "Could not start the review guide."); + } else { + refresh(); + } + } finally { + pending.current = false; + setStarting(false); + } + }; + return { ...query, enabled, canWrite, running, write, startError: error }; +} diff --git a/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.test.ts b/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ca2d8aaa3fc24da7fbaec8f75ad3b2c342be500 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vite-plus/test"; +import { IsoDateTime, type PullRequestActivity, type PullRequestActor } from "@t3tools/contracts"; +import { DEFAULT_ACTIVITY_FILTER, filterPullRequestActivity } from "./pullRequestActivityFilter"; + +const date = IsoDateTime.make("2026-10-11T00:00:00.000Z"); +const human: PullRequestActor = { login: "reader", name: null, avatarUrl: null }; +const bot: PullRequestActor = { ...human, login: "reviewer[bot]" }; +const comment = (id: string, author = human, reviewState: string | null = null) => ({ + id, + author, + reviewState, + kind: "issue-comment" as const, + body: "A comment", + createdAt: date, + url: null, + path: null, +}); +const activity: PullRequestActivity = { + comments: [ + comment("human"), + comment("bot", bot), + comment("dismissed", human, "DISMISSED"), + comment("reply"), + ], + commentCount: 4, + commentsTruncated: false, + commits: [{ oid: "abcdef", messageHeadline: "A change", committedDate: date }], + reviewThreads: [ + { + id: "thread", + path: "file.ts", + line: 1, + side: "right", + isResolved: true, + isOutdated: false, + comments: [comment("reply")], + }, + ], +}; + +describe("mobile activity filters", () => { + it("hides bots and finished conversations by default, counting each thread once", () => { + const result = filterPullRequestActivity(activity, DEFAULT_ACTIVITY_FILTER); + expect(result.activity.comments.map((item) => item.id)).toEqual(["human"]); + expect(result.activity.reviewThreads).toEqual([]); + expect(result.activity.commits).toHaveLength(1); + expect(result.hiddenCount).toBe(3); + }); + it("restores bot comments and finished conversations without duplicate thread replies", () => { + const result = filterPullRequestActivity(activity, { + ...DEFAULT_ACTIVITY_FILTER, + bots: true, + resolved: true, + }); + expect(result.activity.comments.map((item) => item.id)).toEqual(["human", "bot", "dismissed"]); + expect(result.activity.reviewThreads).toHaveLength(1); + expect(result.hiddenCount).toBe(0); + }); + it("keeps partially loaded bot discussions visible", () => { + const thread = { + ...activity.reviewThreads[0]!, + isResolved: false, + comments: [comment("bot-reply", bot)], + }; + for (const incomplete of [{ commentCount: 2 }, { nextCommentsCursor: "next" }]) { + const result = filterPullRequestActivity( + { ...activity, reviewThreads: [{ ...thread, ...incomplete }] }, + DEFAULT_ACTIVITY_FILTER, + ); + expect(result.activity.reviewThreads).toHaveLength(1); + } + expect( + filterPullRequestActivity({ ...activity, reviewThreads: [thread] }, DEFAULT_ACTIVITY_FILTER) + .activity.reviewThreads, + ).toEqual([]); + }); + it("provides a commits-only view and a comments-only view", () => { + const commits = filterPullRequestActivity(activity, { + ...DEFAULT_ACTIVITY_FILTER, + kind: "commits", + }).activity; + expect(commits.comments).toEqual([]); + expect(commits.reviewThreads).toEqual([]); + expect(commits.commits).toHaveLength(1); + expect( + filterPullRequestActivity(activity, { ...DEFAULT_ACTIVITY_FILTER, kind: "comments" }).activity + .commits, + ).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts b/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts new file mode 100644 index 0000000000000000000000000000000000000000..26814d7111ef477c80e0e83eb8950879a060d6eb --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts @@ -0,0 +1,66 @@ +import type { PullRequestActivity, PullRequestActor } from "@t3tools/contracts"; + +export interface ActivityFilter { + readonly kind: "all" | "comments" | "commits"; + readonly bots: boolean; + readonly resolved: boolean; + readonly newestFirst: boolean; +} + +export const DEFAULT_ACTIVITY_FILTER: ActivityFilter = { + kind: "all", + bots: false, + resolved: false, + newestFirst: true, +}; + +function isBot(actor: PullRequestActor | null) { + return actor?.isBot === true || actor?.login.endsWith("[bot]") === true; +} + +function eventCount(activity: PullRequestActivity) { + const discussionIds = new Set( + activity.reviewThreads.flatMap((thread) => thread.comments.map((comment) => comment.id)), + ); + return ( + activity.comments.filter((comment) => !discussionIds.has(comment.id)).length + + activity.reviewThreads.length + + activity.commits.length + ); +} + +export function filterPullRequestActivity(activity: PullRequestActivity, filter: ActivityFilter) { + // A discussion's replies stay together, even when a bot replies to a human. + const reviewThreads = + filter.kind === "commits" + ? [] + : activity.reviewThreads.filter( + (thread) => + (filter.resolved || !thread.isResolved) && + (filter.bots || + (thread.commentCount ?? 0) > thread.comments.length || + thread.nextCommentsCursor != null || + thread.comments.some((comment) => !isBot(comment.author))), + ); + const allDiscussionIds = new Set( + activity.reviewThreads.flatMap((thread) => thread.comments.map((comment) => comment.id)), + ); + const comments = + filter.kind === "commits" + ? [] + : activity.comments.filter( + (comment) => + !allDiscussionIds.has(comment.id) && + (filter.bots || !isBot(comment.author)) && + (filter.resolved || comment.reviewState?.trim().toLowerCase() !== "dismissed"), + ); + const filtered = { + ...activity, + comments, + reviewThreads, + commits: filter.kind === "comments" ? [] : activity.commits, + commentsTruncated: filter.kind !== "commits" && activity.commentsTruncated, + reviewThreadsTruncated: filter.kind !== "commits" && activity.reviewThreadsTruncated, + }; + return { activity: filtered, hiddenCount: eventCount(activity) - eventCount(filtered) }; +} diff --git a/apps/mobile/src/features/pull-requests/pullRequestAnimations.ts b/apps/mobile/src/features/pull-requests/pullRequestAnimations.ts new file mode 100644 index 0000000000000000000000000000000000000000..c50e5b065682e6a2755009db0af487e6ed7bcff2 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestAnimations.ts @@ -0,0 +1,37 @@ +import { + FadeIn, + FadeOut, + LinearTransition, + ReduceMotion, + withTiming, + type LayoutAnimationFunction, +} from "react-native-reanimated"; + +// Match the model picker: move existing rows, briefly fade additions and removals. +export const PR_ROW_LAYOUT = LinearTransition.duration(180).reduceMotion(ReduceMotion.System); +export const PR_ROW_ENTER = FadeIn.duration(140).reduceMotion(ReduceMotion.System); +export const PR_ROW_EXIT = FadeOut.duration(100).reduceMotion(ReduceMotion.System); + +export const PR_DISCLOSURE_ENTER = FadeIn.delay(80).duration(120).reduceMotion(ReduceMotion.System); + +// Follow expanding status rows without interpolating the clipped markdown's height. +export const PR_POSITION_LAYOUT: LayoutAnimationFunction = (values) => { + "worklet"; + return { + initialValues: { + originX: values.targetOriginX, + originY: values.currentOriginY, + width: values.targetWidth, + height: values.targetHeight, + }, + animations: { + originX: values.targetOriginX, + originY: withTiming(values.targetOriginY, { + duration: 180, + reduceMotion: ReduceMotion.System, + }), + width: values.targetWidth, + height: values.targetHeight, + }, + }; +}; diff --git a/apps/mobile/src/features/pull-requests/pullRequestFileFilters.test.ts b/apps/mobile/src/features/pull-requests/pullRequestFileFilters.test.ts index f1977bd5b1762d452a2dadd4b03e98651253dafb..1e709f29558b19a89effd3115f1af55ab312142a 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestFileFilters.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestFileFilters.test.ts @@ -67,6 +67,17 @@ describe("mobile PR test file filter", () => { expect(parsed.files).toHaveLength(4); }); + it("keeps a guide's target test file visible without exposing other hidden tests", () => { + const parsed = buildReviewParsedDiff( + ["src/first.test.ts", "src/app.ts", "src/second.test.ts"].map(patch).join("\n"), + "pr", + ); + const visible = filterPullRequestTestFiles(parsed, true, "src/second.test.ts"); + if (parsed.kind !== "files" || visible.kind !== "files") throw new Error("Expected files"); + expect(visible.files.map((file) => file.path)).toEqual(["src/app.ts", "src/second.test.ts"]); + expect(visible.files[1]).toBe(parsed.files[2]); + }); + it("uses the current path when a file is renamed out of a test directory", () => { const parsed = buildReviewParsedDiff( "diff --git a/tests/helper.ts b/src/helper.ts\nsimilarity index 100%\nrename from tests/helper.ts\nrename to src/helper.ts\n", diff --git a/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts b/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts index 2e0c7d8d2172e0e9f6510b1f4b999d8983e5e1c8..34277162a35fd1d28dcb1a4e7bfc039d77fe4c9e 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts @@ -21,9 +21,12 @@ export function isPullRequestTestFile(path: string): boolean { export function filterPullRequestTestFiles( parsed: ReviewParsedDiff, hideTestFiles: boolean, + includePath?: string, ): ReviewParsedDiff { if (!hideTestFiles || parsed.kind !== "files") return parsed; - const files = parsed.files.filter((file) => !isPullRequestTestFile(file.path)); + const files = parsed.files.filter( + (file) => file.path === includePath || !isPullRequestTestFile(file.path), + ); if (files.length === parsed.files.length) return parsed; return { ...parsed, diff --git a/apps/mobile/src/features/pull-requests/pullRequestTabScroll.test.ts b/apps/mobile/src/features/pull-requests/pullRequestTabScroll.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..98f35378d7ca77369c1d75fcedef35125a4caae4 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestTabScroll.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vite-plus/test"; +import { pullRequestTabOffset } from "./pullRequestTabScroll"; + +describe("pull request tab scroll", () => { + it("keeps the header fixed while switching, even if the destination was scrolled deeply", () => { + expect(pullRequestTabOffset(-100, 900, 200)).toBe(-100); + expect(pullRequestTabOffset(75, 900, 200)).toBe(75); + }); + it("resumes the destination body only when the shared header is already collapsed", () => { + expect(pullRequestTabOffset(600, 900, 200)).toBe(900); + expect(pullRequestTabOffset(600, 50, 200)).toBe(200); + expect(pullRequestTabOffset(600, undefined, 200)).toBe(200); + }); +}); diff --git a/apps/mobile/src/features/pull-requests/pullRequestTabScroll.ts b/apps/mobile/src/features/pull-requests/pullRequestTabScroll.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b956f228044116b217582c256e29740c0b6187f --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestTabScroll.ts @@ -0,0 +1,8 @@ +/** Preserve the shared header's position, then each tab's own reading position. */ +export function pullRequestTabOffset( + current: number, + saved: number | undefined, + headerEnd: number, +) { + return current < headerEnd ? current : Math.max(headerEnd, saved ?? headerEnd); +} diff --git a/apps/mobile/src/features/pull-requests/usePullRequestFileFilter.ts b/apps/mobile/src/features/pull-requests/usePullRequestFileFilter.ts index 0dabe52fa1e6b75471e2c9f7efe724b33e218376..d8d824c1ae5fc09d0a600843cd64799e917381a3 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestFileFilter.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestFileFilter.ts @@ -5,14 +5,14 @@ import type { ReviewParsedDiff } from "../review/reviewModel"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { filterPullRequestTestFiles } from "./pullRequestFileFilters"; -export function usePullRequestFileFilter(parsed: ReviewParsedDiff) { +export function usePullRequestFileFilter(parsed: ReviewParsedDiff, includePath?: string) { const preferences = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const hideTestFiles = AsyncResult.isSuccess(preferences) && preferences.value.pullRequestHideTestFiles === true; const visible = useMemo( - () => filterPullRequestTestFiles(parsed, hideTestFiles), - [parsed, hideTestFiles], + () => filterPullRequestTestFiles(parsed, hideTestFiles, includePath), + [parsed, hideTestFiles, includePath], ); const hiddenCount = parsed.kind === "files" && visible.kind === "files" ? parsed.fileCount - visible.fileCount : 0; diff --git a/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx index 98a5e85787dc118d6138fc359576d09860366051..f5490402a5eb718fa4d599d1af6511643a369115 100644 --- a/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx @@ -1,7 +1,7 @@ import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { useCallback, useMemo, useState, type ReactNode } from "react"; import { Keyboard, Platform, View, type NativeSyntheticEvent } from "react-native"; -import { Tabs, type TabSelectedEvent } from "react-native-screens"; +import { Tabs, type TabSelectedEvent, type TabSelectionRejectedEvent } from "react-native-screens"; import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; import type { WorkspaceTab } from "./workspace-tabs-state"; @@ -70,11 +70,16 @@ export function WorkspaceNativeTabs(props: { tabBarMinimizeBehavior: "never", tabBarLeadingWidth: phoneBar ? 220 : 0, }} + onTabSelectionRejected={({ + nativeEvent, + }: NativeSyntheticEvent) => { + setProvenance(nativeEvent.provenance); + }} onTabSelected={({ nativeEvent }: NativeSyntheticEvent) => { const tab = nativeEvent.selectedScreenKey; if (tab !== "threads" && tab !== "pullRequests") return; setProvenance(nativeEvent.provenance); - if (tab === selectedTab) return; + if (nativeEvent.actionOrigin === "programmatic-js" || tab === selectedTab) return; Keyboard.dismiss(); props.onSelect(); workspace.dispatch({ type: "select", tab }); diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx index ffff93bf8455f229966f8ac5fd7b76ef3f5ef396..81474586220b8ec13037afea21606285e9def3be 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx @@ -2,7 +2,7 @@ import { useNavigation } from "@react-navigation/native"; import { use, useCallback, useMemo } from "react"; import { Platform, View, useWindowDimensions } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; import { CompactBrandTitle } from "../../components/CompactBrandTitle"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; @@ -24,7 +24,8 @@ import { } from "./WorkspaceTabsContext"; import { WorkspaceToolbar } from "./WorkspaceToolbar"; import { WORKSPACE_DOCK, workspaceToolbarHeight } from "./workspace-tabs-state"; -import { useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar"; +import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs"; +import { useSidebarWorkspaceTabs, useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar"; const TAB_TITLES = { threads: "Threads", @@ -58,6 +59,7 @@ function WorkspaceRootContent() { const columnMetrics = useNativeColumnLayoutMetrics(); const nativeMetrics = useNativeLayoutMetrics(); const systemTabBar = useSystemWorkspaceTabBar(); + const sidebarTabs = useSidebarWorkspaceTabs(); const sidebarNavigation = primaryColumn !== null && (layout.usesSplitView || @@ -86,7 +88,7 @@ function WorkspaceRootContent() { const bottomClearance = Platform.OS === "ios" ? Math.max(insets.bottom, keyboardHeight) + - (systemTabBar || tab.searchMode !== "resting" + (systemTabBar || sidebarTabs || tab.searchMode !== "resting" ? 16 : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16) : keyboardHeight > 0 && tab.searchMode !== "resting" @@ -96,7 +98,10 @@ function WorkspaceRootContent() { return ( - + {Platform.OS === "ios" ? ( )} - {state.selectedTab === "threads" ? : } + {sidebarTabs ? : null} + {/* The pinned picker already clears the navigation bar; lists must not inset it again. */} + + {state.selectedTab === "threads" ? : } + navigation.navigate("NewTaskSheet", { screen: "NewTask" })} diff --git a/apps/mobile/src/features/workspace/WorkspaceSidebarTabs.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceSidebarTabs.ios.tsx new file mode 100644 index 0000000000000000000000000000000000000000..40420272dccad64e0674a3df1b2c81ad956f7921 --- /dev/null +++ b/apps/mobile/src/features/workspace/WorkspaceSidebarTabs.ios.tsx @@ -0,0 +1,38 @@ +import { Host, Picker, Text } from "@expo/ui/swift-ui"; +import { pickerStyle, tag } from "@expo/ui/swift-ui/modifiers"; +import { StackActions, useNavigation } from "@react-navigation/native"; +import { Keyboard, View } from "react-native"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { useWorkspaceTabs } from "./WorkspaceTabsContext"; +import type { WorkspaceTab } from "./workspace-tabs-state"; + +/** Wide iPad navigation belongs to the sidebar, leaving the detail's header unobstructed. */ +export function WorkspaceSidebarTabs() { + const workspace = useWorkspaceTabs()!; + const navigation = useNavigation(); + const { themeAppearance } = useAppearancePreferences(); + return ( + + + { + if (tab === workspace.state.selectedTab) return; + Keyboard.dismiss(); + if (navigation.getState()?.index) navigation.dispatch(StackActions.popToTop()); + workspace.dispatch({ type: "select", tab }); + }} + modifiers={[pickerStyle("segmented")]} + > + Threads + Pull requests + + + + ); +} diff --git a/apps/mobile/src/features/workspace/WorkspaceSidebarTabs.tsx b/apps/mobile/src/features/workspace/WorkspaceSidebarTabs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..676f82391a3a74908e3db7a7819543a5a918f214 --- /dev/null +++ b/apps/mobile/src/features/workspace/WorkspaceSidebarTabs.tsx @@ -0,0 +1,4 @@ +/** The sidebar workspace picker is native to wide iPad layouts. */ +export function WorkspaceSidebarTabs() { + return null; +} diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx index 3f81b4c5e80552dca9c178358f004468a04bd16b..1a441df9ea0a2cdd552bc000d2e7c45aeb0d80f1 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx @@ -9,20 +9,17 @@ import { NativeWorkspaceTabActivityContext } from "../../native/v5-workspace-con import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; -import { useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar"; -/** Root actions stay in the header; UIKit owns the tab bar and bottom compose button. */ +/** Root actions stay in the header; UIKit owns the bottom compose button. */ export function WorkspaceToolbar(props: { readonly onNewTask: () => void; readonly sidebar?: boolean; }) { const workspace = useWorkspaceTabs()!; const navigation = useNavigation(); - const systemTabBar = useSystemWorkspaceTabBar(); const metrics = useNativeLayoutMetrics(); const nativeToolbarCompose = - systemTabBar && - ((Platform.OS === "ios" && Platform.isPad) || (metrics?.verticalBarEdge ?? "none") !== "none"); + Platform.OS === "ios" && (Platform.isPad || (metrics?.verticalBarEdge ?? "none") !== "none"); const { isTabActive } = workspace; const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true; const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); diff --git a/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts b/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts index 81cd1a7d2bcc1604dd10b44560f648f5a1abf118..e2d5a0c8e53410ca9d5ac25dba6bc4055462db62 100644 --- a/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts +++ b/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts @@ -1,6 +1,13 @@ import { Platform } from "react-native"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; -/** UIKit owns workspace tab navigation on every iOS size class. */ +export function useSidebarWorkspaceTabs() { + const { layout } = useAdaptiveWorkspaceLayout(); + return Platform.OS === "ios" && Platform.isPad && layout.usesSplitView; +} + +/** Compact iOS windows use UIKit tabs; wide iPad windows use the sidebar picker. */ export function useSystemWorkspaceTabBar() { - return Platform.OS === "ios"; + const sidebarTabs = useSidebarWorkspaceTabs(); + return Platform.OS === "ios" && !sidebarTabs; } diff --git a/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx b/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx index 5700272b4e7171639c6bb396fb1fb27b3f15f4ad..4b9747ec71db04255c0f6b57686cf4186fedf886 100644 --- a/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx +++ b/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx @@ -36,7 +36,7 @@ import { excerptRows, fileDiffRows, rowsToPatch, -} from "./guideTour.logic"; +} from "@t3tools/client-runtime/review-guide-tour"; import { GuideNoteMarker } from "./GuideNotes"; import { GuideProse } from "./GuideProse"; diff --git a/apps/web/src/components/pullRequest/guide/GuideRail.tsx b/apps/web/src/components/pullRequest/guide/GuideRail.tsx index 01c17652b9647a5b91922ba55d1676ef2b8e42d5..8ff930af8e65d90e01089e599b5d003b6c84b1d8 100644 --- a/apps/web/src/components/pullRequest/guide/GuideRail.tsx +++ b/apps/web/src/components/pullRequest/guide/GuideRail.tsx @@ -4,7 +4,7 @@ import { CheckIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Badge } from "../../ui/badge"; -import { stopSubline } from "./guideTour.logic"; +import { stopSubline } from "@t3tools/client-runtime/review-guide-tour"; /** * The tour's table of contents: where the reader is and which stops they have read past. A stop diff --git a/apps/web/src/components/pullRequest/guide/GuideStop.tsx b/apps/web/src/components/pullRequest/guide/GuideStop.tsx index 2b6f207f8fdbcd4e84b2ae5edb32b0ed25981584..bfd1aed88e029d3865cff985e5f961cb168d1f86 100644 --- a/apps/web/src/components/pullRequest/guide/GuideStop.tsx +++ b/apps/web/src/components/pullRequest/guide/GuideStop.tsx @@ -28,7 +28,7 @@ import { GuideExcerpt, type ExcerptCommenting } from "./GuideExcerpt"; import { GuideFileDiff } from "./GuideFileDiff"; import { GuideNotes } from "./GuideNotes"; import { GuideProse } from "./GuideProse"; -import { letterStopNotes } from "./guideTour.logic"; +import { letterStopNotes } from "@t3tools/client-runtime/review-guide-tour"; const ATTENTION_LABELS = { tradeoff: "A tradeoff to weigh", diff --git a/apps/web/src/components/pullRequest/guide/GuideTour.tsx b/apps/web/src/components/pullRequest/guide/GuideTour.tsx index 36effffabb2a7e4e27aeb6298e814358c77c8349..2c0846649c6184e070d74e51b7186e775f932389 100644 --- a/apps/web/src/components/pullRequest/guide/GuideTour.tsx +++ b/apps/web/src/components/pullRequest/guide/GuideTour.tsx @@ -18,7 +18,7 @@ import { useGuideRunStore, type GuideProgress, } from "./guideRunStore"; -import { stepTourPosition, stopFilePaths } from "./guideTour.logic"; +import { stepTourPosition, stopFilePaths } from "@t3tools/client-runtime/review-guide-tour"; import { useGuideDiffFiles } from "./useGuideDiffFiles"; /** diff --git a/apps/web/src/components/pullRequest/guide/guideTour.logic.test.ts b/apps/web/src/components/pullRequest/guide/guideTour.logic.test.ts deleted file mode 100644 index 78e6451310f74dcb23f790fed6dfd982ad97cc95..0000000000000000000000000000000000000000 --- a/apps/web/src/components/pullRequest/guide/guideTour.logic.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; -import type { PullRequestReviewGuideStop } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - excerptExpansionOldContents, - excerptRows, - fileDiffRows, - letterStopNotes, - rowsToPatch, - stepTourPosition, -} from "./guideTour.logic"; - -const PATCH = `diff --git a/src/tray.ts b/src/tray.ts ---- a/src/tray.ts -+++ b/src/tray.ts -@@ -10,5 +10,6 @@ function tray() - const a = 1; --const size = pick(); -+const size = "xs"; -+const variant = "tray"; - const b = 2; - const c = 3; - const d = 4; -@@ -40,3 +41,2 @@ function footer() - keep(); --dropped(); - tail(); -`; - -function parse(patch: string) { - const file = parsePatchFiles(patch, `test:${patch.length}`)[0]?.files[0]; - if (file === undefined) throw new Error("patch did not parse"); - return file; -} - -const rows = fileDiffRows(parse(PATCH)); - -function stop(overrides: Partial): PullRequestReviewGuideStop { - return { - title: "Stop", - summary: "", - attention: null, - excerpts: [], - files: [], - notes: [], - questions: [], - ...overrides, - }; -} - -describe("stepTourPosition", () => { - it("holds the tour to its first and last stops", () => { - expect(stepTourPosition(0, -1, 3)).toBe(0); - expect(stepTourPosition(0, 1, 3)).toBe(1); - expect(stepTourPosition(2, 1, 3)).toBe(2); - }); -}); - -describe("fileDiffRows", () => { - it("numbers both sides through context, deletions and additions", () => { - expect(rows.map((row) => [row.kind, row.oldLine, row.newLine])).toEqual([ - ["context", 10, 10], - ["deletion", 11, null], - ["addition", null, 11], - ["addition", null, 12], - ["context", 12, 13], - ["context", 13, 14], - ["context", 14, 15], - ["context", 40, 41], - ["deletion", 41, null], - ["context", 42, 42], - ]); - }); -}); - -describe("excerptRows", () => { - it("brings the replaced lines along with an excerpt that starts on an addition", () => { - const shown = excerptRows(rows, { side: "new", start: 11, end: 12 }); - expect(shown.map((row) => row.kind)).toEqual(["deletion", "addition", "addition"]); - }); - - it("selects old-side lines for a removal and stops at the hunk boundary", () => { - const shown = excerptRows(rows, { side: "old", start: 41, end: 41 }); - expect(shown.map((row) => [row.kind, row.oldLine])).toEqual([["deletion", 41]]); - }); - - it("returns nothing for a range outside the diff", () => { - expect(excerptRows(rows, { side: "new", start: 100, end: 120 })).toEqual([]); - }); -}); - -describe("rowsToPatch", () => { - it("cuts a slice that parses back to the same lines and numbers", () => { - const shown = excerptRows(rows, { side: "new", start: 10, end: 13 }); - const slice = parse(rowsToPatch("src/tray.ts", "src/tray.ts", shown)); - expect(fileDiffRows(slice).map((row) => [row.kind, row.oldLine, row.newLine])).toEqual( - shown.map((row) => [row.kind, row.oldLine, row.newLine]), - ); - }); - - it("starts a hunk per source hunk so separate hunks stay separate", () => { - const shown = excerptRows(rows, { side: "old", start: 14, end: 42 }); - const patch = rowsToPatch("src/tray.ts", "src/tray.ts", shown); - expect(patch.match(/^@@ /gm)).toHaveLength(2); - expect(patch).toContain("@@ -14,1 +15,1 @@"); - expect(patch).toContain("@@ -40,3 +41,2 @@"); - }); -}); - -describe("excerptExpansionOldContents", () => { - // The new file behind PATCH: lines 1-9, the hunk at 10-15, then lines up to 41-42 around the - // second hunk, which removed a line. - const newFile = Array.from({ length: 50 }, (_, index) => `line ${index + 1}\n`); - newFile.splice( - 9, - 6, - "const a = 1;\n", - 'const size = "xs";\n', - 'const variant = "tray";\n', - "const b = 2;\n", - "const c = 3;\n", - "const d = 4;\n", - ); - newFile.splice(40, 2, "keep();\n", "tail();\n"); - const newContents = newFile.join(""); - - it("puts back only the excerpt's lines and keeps both sides numbered as in the file", () => { - const shown = excerptRows(rows, { side: "new", start: 11, end: 12 }); - const old = excerptExpansionOldContents(newContents, shown); - if (old === null) throw new Error("expected contents"); - const oldLines = old.split("\n"); - // The excerpt's old side sits at its real old line. - expect(oldLines[10]).toBe("const size = pick();"); - // Around it, the new file's lines, so the two sides agree outside the excerpt. - expect(oldLines[9]).toBe("const a = 1;"); - expect(oldLines[11]).toBe("const b = 2;"); - // One line replaced by two: the old side is a line shorter, everything after lines up. - expect(oldLines.length).toBe(newContents.split("\n").length - 1); - }); - - it("does not try to expand an excerpt that spans hunks", () => { - const shown = excerptRows(rows, { side: "old", start: 14, end: 42 }); - expect(excerptExpansionOldContents(newContents, shown)).toBeNull(); - }); -}); - -describe("letterStopNotes", () => { - it("letters notes in guide order and pins each to the excerpt that holds its line", () => { - const { lettered, byExcerpt } = letterStopNotes( - stop({ - excerpts: [ - { path: "a.ts", side: "new", start: 1, end: 5, caption: "" }, - { path: "b.ts", side: "new", start: 10, end: 12, caption: "" }, - ], - notes: [ - { path: "b.ts", line: 11, title: "First", body: "" }, - { path: "a.ts", line: 2, title: "Second", body: "" }, - { path: "a.ts", line: 2, title: "Third", body: "" }, - { path: "c.ts", line: 1, title: "Unanchored", body: "" }, - ], - }), - ); - expect(lettered.map((entry) => entry.marker)).toEqual(["A", "B", "C", "D"]); - expect(byExcerpt).toEqual([ - [ - { - line: 2, - markers: [ - { marker: "B", title: "Second" }, - { marker: "C", title: "Third" }, - ], - }, - ], - [{ line: 11, markers: [{ marker: "A", title: "First" }] }], - ]); - }); -}); diff --git a/apps/web/src/components/pullRequest/guide/guideTour.logic.ts b/apps/web/src/components/pullRequest/guide/guideTour.logic.ts deleted file mode 100644 index 653aae14549a19b86f73d4ca1d544a3765f2e0d7..0000000000000000000000000000000000000000 --- a/apps/web/src/components/pullRequest/guide/guideTour.logic.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { FileDiffMetadata } from "@pierre/diffs"; -import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts"; - -/** One step forward or back, held to the tour's first and last stops. */ -export function stepTourPosition(current: number, delta: 1 | -1, stopCount: number): number { - return Math.max(Math.min(current + delta, stopCount - 1), 0); -} - -/** The stop's files in reading order: the excerpted ones first, then the rest it owns. */ -export function stopFilePaths(stop: PullRequestReviewGuideStop): ReadonlyArray { - return [ - ...new Set([ - ...stop.excerpts.map((excerpt) => excerpt.path), - ...stop.files.map((file) => file.path), - ]), - ]; -} - -/** The rail's second line for a stop. */ -export function stopSubline(stop: PullRequestReviewGuideStop): string { - const count = stopFilePaths(stop).length; - if (count === 0) return "Context, no code"; - return count === 1 ? "1 file" : `${count} files`; -} - -/** A note's marker: A, B, C… in the order the guide lists them, numbers past Z. */ -function noteMarker(index: number): string { - return index < 26 ? String.fromCharCode(65 + index) : String(index + 1); -} - -/** One rendered line of a file's diff, with where it sits on each side. */ -export interface DiffRow { - readonly kind: "context" | "addition" | "deletion"; - /** Line number on the old side, null for an added line. */ - readonly oldLine: number | null; - /** Line number on the new side, null for a removed line. */ - readonly newLine: number | null; - /** The next line number on each side when this row is reached, for hunk headers. */ - readonly oldAt: number; - readonly newAt: number; - readonly text: string; - /** Which hunk of the file the row came from; rows from two hunks are not adjacent. */ - readonly hunk: number; -} - -/** Every line of a parsed file diff as the unified view lists it. */ -export function fileDiffRows(file: FileDiffMetadata): ReadonlyArray { - const rows: DiffRow[] = []; - file.hunks.forEach((hunk, hunkIndex) => { - let oldAt = hunk.deletionStart; - let newAt = hunk.additionStart; - for (const content of hunk.hunkContent) { - if (content.type === "context") { - for (let offset = 0; offset < content.lines; offset += 1) { - rows.push({ - kind: "context", - oldLine: oldAt, - newLine: newAt, - oldAt, - newAt, - text: file.additionLines[content.additionLineIndex + offset] ?? "", - hunk: hunkIndex, - }); - oldAt += 1; - newAt += 1; - } - continue; - } - for (let offset = 0; offset < content.deletions; offset += 1) { - rows.push({ - kind: "deletion", - oldLine: oldAt, - newLine: null, - oldAt, - newAt, - text: file.deletionLines[content.deletionLineIndex + offset] ?? "", - hunk: hunkIndex, - }); - oldAt += 1; - } - for (let offset = 0; offset < content.additions; offset += 1) { - rows.push({ - kind: "addition", - oldLine: null, - newLine: newAt, - oldAt, - newAt, - text: file.additionLines[content.additionLineIndex + offset] ?? "", - hunk: hunkIndex, - }); - newAt += 1; - } - } - }); - return rows; -} - -/** - * The rows an excerpt covers. Its range is in one side's line numbers, so the other side's half - * of a replacement at either edge comes along: an excerpt starting on an added line also shows - * the lines it replaced, which is what makes it read as a change rather than as new code. - */ -export function excerptRows( - rows: ReadonlyArray, - excerpt: Pick, -): ReadonlyArray { - const lineOf = (row: DiffRow) => (excerpt.side === "new" ? row.newLine : row.oldLine); - const inRange = (row: DiffRow) => { - const line = lineOf(row); - return line !== null && line >= excerpt.start && line <= excerpt.end; - }; - let first = rows.findIndex(inRange); - if (first === -1) return []; - let last = rows.findLastIndex(inRange); - const sameHunk = (index: number, anchor: number) => rows[index]?.hunk === rows[anchor]?.hunk; - if (excerpt.side === "new" && rows[first]?.kind === "addition") { - while (first > 0 && rows[first - 1]?.kind === "deletion" && sameHunk(first - 1, first)) { - first -= 1; - } - } - if (excerpt.side === "old" && rows[last]?.kind === "deletion") { - while ( - last < rows.length - 1 && - rows[last + 1]?.kind === "addition" && - sameHunk(last + 1, last) - ) { - last += 1; - } - } - return rows.slice(first, last + 1); -} - -const ROW_PREFIX = { context: " ", addition: "+", deletion: "-" } as const; - -/** - * A unified patch of just these rows, so the shared diff renderer can draw a slice of a file - * with its real line numbers instead of a second diff view being written for excerpts. - */ -export function rowsToPatch( - path: string, - previousPath: string, - rows: ReadonlyArray, -): string { - if (rows.length === 0) return ""; - const lines = [ - `diff --git a/${previousPath} b/${path}`, - `--- a/${previousPath}`, - `+++ b/${path}`, - ]; - let start = 0; - while (start < rows.length) { - let end = start; - while (end + 1 < rows.length && rows[end + 1]?.hunk === rows[start]?.hunk) end += 1; - const group = rows.slice(start, end + 1); - const head = group[0]!; - const oldCount = group.filter((row) => row.oldLine !== null).length; - const newCount = group.filter((row) => row.newLine !== null).length; - // Git names the line before an empty side, so a pure insertion after line 9 is `-9,0`. - const oldStart = oldCount === 0 ? Math.max(head.oldAt - 1, 0) : head.oldAt; - const newStart = newCount === 0 ? Math.max(head.newAt - 1, 0) : head.newAt; - lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`); - for (const row of group) lines.push(`${ROW_PREFIX[row.kind]}${row.text.replace(/\r?\n$/, "")}`); - start = end + 1; - } - return `${lines.join("\n")}\n`; -} - -/** - * The old side an excerpt expands against: the new file with only the excerpt's lines put back - * as they were. The renderer fills the code around a hunk from both files and needs them to - * agree outside it, which the real old file does not wherever another hunk changed lines. Only - * the lines the reader can expand into are read, and those are the new file's, so the code - * around the excerpt shows as it reads after the change. Null when the excerpt spans hunks. - */ -export function excerptExpansionOldContents( - newContents: string, - rows: ReadonlyArray, -): string | null { - const head = rows[0]; - if (head === undefined || rows.some((row) => row.hunk !== head.hunk)) return null; - const newLines = newContents.match(/[^\n]*\n|[^\n]+$/g) ?? []; - const before = head.newAt - 1; - const newCount = rows.filter((row) => row.newLine !== null).length; - // The old side keeps its own numbering, so its prefix is as long as the old file's: trimmed - // from the front, or padded there with lines no expansion reaches. - const oldBefore = head.oldAt - 1; - const prefix = newLines.slice(0, before); - const oldPrefix = - oldBefore >= prefix.length - ? [...Array.from({ length: oldBefore - prefix.length }, () => "\n"), ...prefix] - : prefix.slice(prefix.length - oldBefore); - const oldRows = rows - .filter((row) => row.oldLine !== null) - .map((row) => (row.text.endsWith("\n") ? row.text : `${row.text}\n`)); - return [...oldPrefix, ...oldRows, ...newLines.slice(before + newCount)].join(""); -} - -/** A stop's notes lettered in guide order, with the excerpt lines each one marks. */ -export interface LetteredNote { - readonly marker: string; - readonly note: Note; -} - -/** - * Letters a stop's notes and groups them by the excerpt line they sit on. A note outside every - * excerpt keeps its letter in the list; it just has no line to mark. - */ -export function letterStopNotes(stop: PullRequestReviewGuideStop) { - const lettered = stop.notes.map((note, index) => ({ marker: noteMarker(index), note })); - const byExcerpt = stop.excerpts.map((excerpt) => { - const lines = new Map>(); - for (const { marker, note } of lettered) { - if (note.path !== excerpt.path || note.line < excerpt.start || note.line > excerpt.end) { - continue; - } - const entry = { marker, title: note.title }; - const list = lines.get(note.line); - if (list === undefined) lines.set(note.line, [entry]); - else list.push(entry); - } - return [...lines].map(([line, markers]) => ({ line, markers })); - }); - return { lettered, byExcerpt }; -} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 899c566347bb22f09ce25a44a9f5eb02e2a84576..eacccabc5f4100cc9af28c058431e36c46b89533 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -193,7 +193,9 @@ declined pull request. ### Review guides Review guides are in beta. Turn on **Review guides** in Settings → Source Control to add the -**Guide** tab to pull requests on that server. +**Guide** tab to pull requests on that server. On mobile, move between stops with **Next** +and the back button; a stale guide offers a rewrite and hides excerpts that no longer match +the current change. The **Guide** tab gives you a tour of a pull request without reading it line by line: stops 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 Choose **Write guide** to have the review guide model from Settings → Source Control read the change in a temporary checkout; without one set, the source control writer model is used. Nothing in the checkout is changed, and a large pull request takes a few minutes; you can go elsewhere in -the app and you are told when the guide is ready. Claude and Codex can write guides. The guide is +the app. On web, you are told when the guide is ready; on mobile, return to the guide to check +its progress. Claude and Codex can write guides. The guide is kept for everyone using this server. To write it again, for example after a push or after changing the model or instructions, choose **Rewrite guide** from the pull request's **⋯** menu. -While reading a stop, select lines in an excerpt to add a comment to your review or ask an agent +On web, while reading a stop, select lines in an excerpt to add a comment to your review or ask an agent about them; comments wait with the ones from the **Code** tab until you send the review. Choose a suggested question, or **Ask about this stop**, to open a thread that already knows which stop you are on. **Finish review** on the last stop opens the review with everything you collected. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 4f1d4e20752ab56b11bbe84ecef06b3d414cce98..d7191193b101a0974b6379582cbe9eb2cb3cab51 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -398,6 +398,10 @@ "./source-control-clients": { "types": "./src/sourceControlClients.ts", "default": "./src/sourceControlClients.ts" + }, + "./review-guide-tour": { + "types": "./src/reviewGuideTour.ts", + "default": "./src/reviewGuideTour.ts" } }, "scripts": { @@ -405,6 +409,7 @@ "test": "vp test run" }, "dependencies": { + "@pierre/diffs": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-azure-devops": "workspace:*", diff --git a/packages/client-runtime/src/reviewGuideTour.test.ts b/packages/client-runtime/src/reviewGuideTour.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f784a3433fcbf2d3b2e810ef2b53b5557079311a --- /dev/null +++ b/packages/client-runtime/src/reviewGuideTour.test.ts @@ -0,0 +1,178 @@ +import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; +import type { PullRequestReviewGuideStop } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + excerptExpansionOldContents, + excerptRows, + fileDiffRows, + letterStopNotes, + rowsToPatch, + stepTourPosition, +} from "./reviewGuideTour.js"; + +const PATCH = `diff --git a/src/tray.ts b/src/tray.ts +--- a/src/tray.ts ++++ b/src/tray.ts +@@ -10,5 +10,6 @@ function tray() + const a = 1; +-const size = pick(); ++const size = "xs"; ++const variant = "tray"; + const b = 2; + const c = 3; + const d = 4; +@@ -40,3 +41,2 @@ function footer() + keep(); +-dropped(); + tail(); +`; + +function parse(patch: string) { + const file = parsePatchFiles(patch, `test:${patch.length}`)[0]?.files[0]; + if (file === undefined) throw new Error("patch did not parse"); + return file; +} + +const rows = fileDiffRows(parse(PATCH)); + +function stop(overrides: Partial): PullRequestReviewGuideStop { + return { + title: "Stop", + summary: "", + attention: null, + excerpts: [], + files: [], + notes: [], + questions: [], + ...overrides, + }; +} + +describe("stepTourPosition", () => { + it("holds the tour to its first and last stops", () => { + expect(stepTourPosition(0, -1, 3)).toBe(0); + expect(stepTourPosition(0, 1, 3)).toBe(1); + expect(stepTourPosition(2, 1, 3)).toBe(2); + }); +}); + +describe("fileDiffRows", () => { + it("numbers both sides through context, deletions and additions", () => { + expect(rows.map((row) => [row.kind, row.oldLine, row.newLine])).toEqual([ + ["context", 10, 10], + ["deletion", 11, null], + ["addition", null, 11], + ["addition", null, 12], + ["context", 12, 13], + ["context", 13, 14], + ["context", 14, 15], + ["context", 40, 41], + ["deletion", 41, null], + ["context", 42, 42], + ]); + }); +}); + +describe("excerptRows", () => { + it("brings the replaced lines along with an excerpt that starts on an addition", () => { + const shown = excerptRows(rows, { side: "new", start: 11, end: 12 }); + expect(shown.map((row) => row.kind)).toEqual(["deletion", "addition", "addition"]); + }); + + it("selects old-side lines for a removal and stops at the hunk boundary", () => { + const shown = excerptRows(rows, { side: "old", start: 41, end: 41 }); + expect(shown.map((row) => [row.kind, row.oldLine])).toEqual([["deletion", 41]]); + }); + + it("returns nothing for a range outside the diff", () => { + expect(excerptRows(rows, { side: "new", start: 100, end: 120 })).toEqual([]); + }); +}); + +describe("rowsToPatch", () => { + it("cuts a slice that parses back to the same lines and numbers", () => { + const shown = excerptRows(rows, { side: "new", start: 10, end: 13 }); + const slice = parse(rowsToPatch("src/tray.ts", "src/tray.ts", shown)); + expect(fileDiffRows(slice).map((row) => [row.kind, row.oldLine, row.newLine])).toEqual( + shown.map((row) => [row.kind, row.oldLine, row.newLine]), + ); + }); + + it("starts a hunk per source hunk so separate hunks stay separate", () => { + const shown = excerptRows(rows, { side: "old", start: 14, end: 42 }); + const patch = rowsToPatch("src/tray.ts", "src/tray.ts", shown); + expect(patch.match(/^@@ /gm)).toHaveLength(2); + expect(patch).toContain("@@ -14,1 +15,1 @@"); + expect(patch).toContain("@@ -40,3 +41,2 @@"); + }); +}); + +describe("excerptExpansionOldContents", () => { + // The new file behind PATCH: lines 1-9, the hunk at 10-15, then lines up to 41-42 around the + // second hunk, which removed a line. + const newFile = Array.from({ length: 50 }, (_, index) => `line ${index + 1}\n`); + newFile.splice( + 9, + 6, + "const a = 1;\n", + 'const size = "xs";\n', + 'const variant = "tray";\n', + "const b = 2;\n", + "const c = 3;\n", + "const d = 4;\n", + ); + newFile.splice(40, 2, "keep();\n", "tail();\n"); + const newContents = newFile.join(""); + + it("puts back only the excerpt's lines and keeps both sides numbered as in the file", () => { + const shown = excerptRows(rows, { side: "new", start: 11, end: 12 }); + const old = excerptExpansionOldContents(newContents, shown); + if (old === null) throw new Error("expected contents"); + const oldLines = old.split("\n"); + // The excerpt's old side sits at its real old line. + expect(oldLines[10]).toBe("const size = pick();"); + // Around it, the new file's lines, so the two sides agree outside the excerpt. + expect(oldLines[9]).toBe("const a = 1;"); + expect(oldLines[11]).toBe("const b = 2;"); + // One line replaced by two: the old side is a line shorter, everything after lines up. + expect(oldLines.length).toBe(newContents.split("\n").length - 1); + }); + + it("does not try to expand an excerpt that spans hunks", () => { + const shown = excerptRows(rows, { side: "old", start: 14, end: 42 }); + expect(excerptExpansionOldContents(newContents, shown)).toBeNull(); + }); +}); + +describe("letterStopNotes", () => { + it("letters notes in guide order and pins each to the excerpt that holds its line", () => { + const { lettered, byExcerpt } = letterStopNotes( + stop({ + excerpts: [ + { path: "a.ts", side: "new", start: 1, end: 5, caption: "" }, + { path: "b.ts", side: "new", start: 10, end: 12, caption: "" }, + ], + notes: [ + { path: "b.ts", line: 11, title: "First", body: "" }, + { path: "a.ts", line: 2, title: "Second", body: "" }, + { path: "a.ts", line: 2, title: "Third", body: "" }, + { path: "c.ts", line: 1, title: "Unanchored", body: "" }, + ], + }), + ); + expect(lettered.map((entry) => entry.marker)).toEqual(["A", "B", "C", "D"]); + expect(byExcerpt).toEqual([ + [ + { + line: 2, + markers: [ + { marker: "B", title: "Second" }, + { marker: "C", title: "Third" }, + ], + }, + ], + [{ line: 11, markers: [{ marker: "A", title: "First" }] }], + ]); + }); +}); diff --git a/packages/client-runtime/src/reviewGuideTour.ts b/packages/client-runtime/src/reviewGuideTour.ts new file mode 100644 index 0000000000000000000000000000000000000000..653aae14549a19b86f73d4ca1d544a3765f2e0d7 --- /dev/null +++ b/packages/client-runtime/src/reviewGuideTour.ts @@ -0,0 +1,224 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts"; + +/** One step forward or back, held to the tour's first and last stops. */ +export function stepTourPosition(current: number, delta: 1 | -1, stopCount: number): number { + return Math.max(Math.min(current + delta, stopCount - 1), 0); +} + +/** The stop's files in reading order: the excerpted ones first, then the rest it owns. */ +export function stopFilePaths(stop: PullRequestReviewGuideStop): ReadonlyArray { + return [ + ...new Set([ + ...stop.excerpts.map((excerpt) => excerpt.path), + ...stop.files.map((file) => file.path), + ]), + ]; +} + +/** The rail's second line for a stop. */ +export function stopSubline(stop: PullRequestReviewGuideStop): string { + const count = stopFilePaths(stop).length; + if (count === 0) return "Context, no code"; + return count === 1 ? "1 file" : `${count} files`; +} + +/** A note's marker: A, B, C… in the order the guide lists them, numbers past Z. */ +function noteMarker(index: number): string { + return index < 26 ? String.fromCharCode(65 + index) : String(index + 1); +} + +/** One rendered line of a file's diff, with where it sits on each side. */ +export interface DiffRow { + readonly kind: "context" | "addition" | "deletion"; + /** Line number on the old side, null for an added line. */ + readonly oldLine: number | null; + /** Line number on the new side, null for a removed line. */ + readonly newLine: number | null; + /** The next line number on each side when this row is reached, for hunk headers. */ + readonly oldAt: number; + readonly newAt: number; + readonly text: string; + /** Which hunk of the file the row came from; rows from two hunks are not adjacent. */ + readonly hunk: number; +} + +/** Every line of a parsed file diff as the unified view lists it. */ +export function fileDiffRows(file: FileDiffMetadata): ReadonlyArray { + const rows: DiffRow[] = []; + file.hunks.forEach((hunk, hunkIndex) => { + let oldAt = hunk.deletionStart; + let newAt = hunk.additionStart; + for (const content of hunk.hunkContent) { + if (content.type === "context") { + for (let offset = 0; offset < content.lines; offset += 1) { + rows.push({ + kind: "context", + oldLine: oldAt, + newLine: newAt, + oldAt, + newAt, + text: file.additionLines[content.additionLineIndex + offset] ?? "", + hunk: hunkIndex, + }); + oldAt += 1; + newAt += 1; + } + continue; + } + for (let offset = 0; offset < content.deletions; offset += 1) { + rows.push({ + kind: "deletion", + oldLine: oldAt, + newLine: null, + oldAt, + newAt, + text: file.deletionLines[content.deletionLineIndex + offset] ?? "", + hunk: hunkIndex, + }); + oldAt += 1; + } + for (let offset = 0; offset < content.additions; offset += 1) { + rows.push({ + kind: "addition", + oldLine: null, + newLine: newAt, + oldAt, + newAt, + text: file.additionLines[content.additionLineIndex + offset] ?? "", + hunk: hunkIndex, + }); + newAt += 1; + } + } + }); + return rows; +} + +/** + * The rows an excerpt covers. Its range is in one side's line numbers, so the other side's half + * of a replacement at either edge comes along: an excerpt starting on an added line also shows + * the lines it replaced, which is what makes it read as a change rather than as new code. + */ +export function excerptRows( + rows: ReadonlyArray, + excerpt: Pick, +): ReadonlyArray { + const lineOf = (row: DiffRow) => (excerpt.side === "new" ? row.newLine : row.oldLine); + const inRange = (row: DiffRow) => { + const line = lineOf(row); + return line !== null && line >= excerpt.start && line <= excerpt.end; + }; + let first = rows.findIndex(inRange); + if (first === -1) return []; + let last = rows.findLastIndex(inRange); + const sameHunk = (index: number, anchor: number) => rows[index]?.hunk === rows[anchor]?.hunk; + if (excerpt.side === "new" && rows[first]?.kind === "addition") { + while (first > 0 && rows[first - 1]?.kind === "deletion" && sameHunk(first - 1, first)) { + first -= 1; + } + } + if (excerpt.side === "old" && rows[last]?.kind === "deletion") { + while ( + last < rows.length - 1 && + rows[last + 1]?.kind === "addition" && + sameHunk(last + 1, last) + ) { + last += 1; + } + } + return rows.slice(first, last + 1); +} + +const ROW_PREFIX = { context: " ", addition: "+", deletion: "-" } as const; + +/** + * A unified patch of just these rows, so the shared diff renderer can draw a slice of a file + * with its real line numbers instead of a second diff view being written for excerpts. + */ +export function rowsToPatch( + path: string, + previousPath: string, + rows: ReadonlyArray, +): string { + if (rows.length === 0) return ""; + const lines = [ + `diff --git a/${previousPath} b/${path}`, + `--- a/${previousPath}`, + `+++ b/${path}`, + ]; + let start = 0; + while (start < rows.length) { + let end = start; + while (end + 1 < rows.length && rows[end + 1]?.hunk === rows[start]?.hunk) end += 1; + const group = rows.slice(start, end + 1); + const head = group[0]!; + const oldCount = group.filter((row) => row.oldLine !== null).length; + const newCount = group.filter((row) => row.newLine !== null).length; + // Git names the line before an empty side, so a pure insertion after line 9 is `-9,0`. + const oldStart = oldCount === 0 ? Math.max(head.oldAt - 1, 0) : head.oldAt; + const newStart = newCount === 0 ? Math.max(head.newAt - 1, 0) : head.newAt; + lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`); + for (const row of group) lines.push(`${ROW_PREFIX[row.kind]}${row.text.replace(/\r?\n$/, "")}`); + start = end + 1; + } + return `${lines.join("\n")}\n`; +} + +/** + * The old side an excerpt expands against: the new file with only the excerpt's lines put back + * as they were. The renderer fills the code around a hunk from both files and needs them to + * agree outside it, which the real old file does not wherever another hunk changed lines. Only + * the lines the reader can expand into are read, and those are the new file's, so the code + * around the excerpt shows as it reads after the change. Null when the excerpt spans hunks. + */ +export function excerptExpansionOldContents( + newContents: string, + rows: ReadonlyArray, +): string | null { + const head = rows[0]; + if (head === undefined || rows.some((row) => row.hunk !== head.hunk)) return null; + const newLines = newContents.match(/[^\n]*\n|[^\n]+$/g) ?? []; + const before = head.newAt - 1; + const newCount = rows.filter((row) => row.newLine !== null).length; + // The old side keeps its own numbering, so its prefix is as long as the old file's: trimmed + // from the front, or padded there with lines no expansion reaches. + const oldBefore = head.oldAt - 1; + const prefix = newLines.slice(0, before); + const oldPrefix = + oldBefore >= prefix.length + ? [...Array.from({ length: oldBefore - prefix.length }, () => "\n"), ...prefix] + : prefix.slice(prefix.length - oldBefore); + const oldRows = rows + .filter((row) => row.oldLine !== null) + .map((row) => (row.text.endsWith("\n") ? row.text : `${row.text}\n`)); + return [...oldPrefix, ...oldRows, ...newLines.slice(before + newCount)].join(""); +} + +/** A stop's notes lettered in guide order, with the excerpt lines each one marks. */ +export interface LetteredNote { + readonly marker: string; + readonly note: Note; +} + +/** + * Letters a stop's notes and groups them by the excerpt line they sit on. A note outside every + * excerpt keeps its letter in the list; it just has no line to mark. + */ +export function letterStopNotes(stop: PullRequestReviewGuideStop) { + const lettered = stop.notes.map((note, index) => ({ marker: noteMarker(index), note })); + const byExcerpt = stop.excerpts.map((excerpt) => { + const lines = new Map>(); + for (const { marker, note } of lettered) { + if (note.path !== excerpt.path || note.line < excerpt.start || note.line > excerpt.end) { + continue; + } + const entry = { marker, title: note.title }; + const list = lines.get(note.line); + if (list === undefined) lines.set(note.line, [entry]); + else list.push(entry); + } + return [...lines].map(([line, markers]) => ({ line, markers })); + }); + return { lettered, byExcerpt }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cff518d9bc4952252ddb3fe541442ede24425b0..e3b0094e6e3b303492ce75803b7d7003e8b8f859 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1073,6 +1073,9 @@ importers: packages/client-runtime: dependencies: + '@pierre/diffs': + specifier: 'catalog:' + 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) '@t3tools/contracts': specifier: workspace:* version: link:../contracts @@ -15766,6 +15769,21 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' + '@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)': + dependencies: + '@pierre/theme': 2.0.0 + '@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) + '@shikijs/transformers': 4.2.0 + diff: 9.0.0 + hast-util-to-html: 9.0.5 + lru_map: 0.4.1 + shiki: 4.2.0 + optionalDependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + transitivePeerDependencies: + - '@shikijs/themes' + '@pierre/theme@1.1.0': optional: true @@ -15803,6 +15821,14 @@ snapshots: react-dom: 19.2.6(react@19.2.6) shiki: 4.2.0 + '@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)': + optionalDependencies: + '@pierre/theme': 2.0.0 + '@shikijs/themes': 4.3.0 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + shiki: 4.2.0 + '@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)': dependencies: '@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)