| author | |
| committer | |
| log | d07a07f33c0a1b317dea6234636976231c3688b5 |
| tree | b3a135cb2156e40d73b386bdb42ca4223b86c2b3 |
| parent | 5cbe82a87006a7ee472f0f9c9fce5d5f24caa795 |
| signature | Signed by PGP key B5690EEEBB952194 |
48 files changed, 1711 insertions(+), 564 deletions(-)
apps/mobile/src/connection/pull-request-cache-store.ts created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | import { EnvironmentQueryStore } from "@t3tools/client-runtime/state/query-persistence"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | |||
| 5 | import { MobileDatabase } from "../persistence/mobile-database"; | ||
| 6 | |||
| 7 | export const layer = Layer.effect( | ||
| 8 | EnvironmentQueryStore, | ||
| 9 | Effect.gen(function* () { | ||
| 10 | const database = yield* MobileDatabase; | ||
| 11 | return { | ||
| 12 | load: (environmentId, key) => | ||
| 13 | database | ||
| 14 | .loadCache(environmentId, "pull-request", `v1:${key}`) | ||
| 15 | .pipe(Effect.catch(() => Effect.succeedNone)), | ||
| 16 | save: (environmentId, key, payload) => | ||
| 17 | database | ||
| 18 | .saveCache(environmentId, "pull-request", `v1:${key}`, 1, payload) | ||
| 19 | .pipe(Effect.catch(() => Effect.logWarning("Offline query database save failed"))), | ||
| 20 | } satisfies NonNullable<typeof EnvironmentQueryStore.Service>; | ||
| 21 | }), | ||
| 22 | ); | ||
apps/mobile/src/features/pull-requests/PullRequestControls.tsx+5-4| ... | @@ -123,7 +123,8 @@ export function PullRequestActionToolbar(props: { | ... | @@ -123,7 +123,8 @@ export function PullRequestActionToolbar(props: { |
| 123 | const agent = usePullRequestAgentActions(props.params, props.pr, props.filePath); | 123 | const agent = usePullRequestAgentActions(props.params, props.pr, props.filePath); |
| 124 | const canReview = permissions.verdicts.length > 0; | 124 | const canReview = permissions.verdicts.length > 0; |
| 125 | const pendingReview = canReview && draft.comments.length > 0; | 125 | const pendingReview = canReview && draft.comments.length > 0; |
| 126 | const canOpenThread = agent.threads.length > 0 || agent.canAsk; | 126 | const showAgentControls = agent.canAsk || !permissions.connected; |
| 127 | const canOpenThread = agent.threads.length > 0 || showAgentControls; | ||
| 127 | const threadLabel = agent.threads.length ? "Continue thread" : "Work on PR"; | 128 | const threadLabel = agent.threads.length ? "Continue thread" : "Work on PR"; |
| 128 | const primaryLabel = pendingReview ? `Submit review · ${draft.comments.length}` : threadLabel; | 129 | const primaryLabel = pendingReview ? `Submit review · ${draft.comments.length}` : threadLabel; |
| 129 | const chooseThread = agent.threads.length > 1; | 130 | const chooseThread = agent.threads.length > 1; |
| ... | @@ -142,7 +143,7 @@ export function PullRequestActionToolbar(props: { | ... | @@ -142,7 +143,7 @@ export function PullRequestActionToolbar(props: { |
| 142 | // Reserve the native outer margins, custom-button padding, and item gaps. | 143 | // Reserve the native outer margins, custom-button padding, and item gaps. |
| 143 | // UIKit needs this space to keep every action out of its overflow menu. | 144 | // UIKit needs this space to keep every action out of its overflow menu. |
| 144 | // Duo's side rail uses an intrinsic icon item instead. | 145 | // Duo's side rail uses an intrinsic icon item instead. |
| 145 | const sideControls = Number(agent.canAsk) + 1; | 146 | const sideControls = Number(showAgentControls) + 1; |
| 146 | const primaryWidth = verticalControls | 147 | const primaryWidth = verticalControls |
| 147 | ? undefined | 148 | ? undefined |
| 148 | : Math.max( | 149 | : Math.max( |
| ... | @@ -166,7 +167,7 @@ export function PullRequestActionToolbar(props: { | ... | @@ -166,7 +167,7 @@ export function PullRequestActionToolbar(props: { |
| 166 | className="flex-row items-center justify-between gap-3 border-t border-border-subtle bg-screen px-4 pt-2" | 167 | className="flex-row items-center justify-between gap-3 border-t border-border-subtle bg-screen px-4 pt-2" |
| 167 | style={{ paddingBottom: Math.max(insets.bottom, 8) }} | 168 | style={{ paddingBottom: Math.max(insets.bottom, 8) }} |
| 168 | > | 169 | > |
| 169 | {agent.canAsk ? ( | 170 | {showAgentControls ? ( |
| 170 | <ControlPillMenu | 171 | <ControlPillMenu |
| 171 | title="AI helpers" | 172 | title="AI helpers" |
| 172 | actions={agentActions.map((action) => agent.menuAction(action.task))} | 173 | actions={agentActions.map((action) => agent.menuAction(action.task))} |
| ... | @@ -259,7 +260,7 @@ export function PullRequestActionToolbar(props: { | ... | @@ -259,7 +260,7 @@ export function PullRequestActionToolbar(props: { |
| 259 | /> | 260 | /> |
| 260 | ) | 261 | ) |
| 261 | ) : null; | 262 | ) : null; |
| 262 | const agentControl = agent.canAsk ? ( | 263 | const agentControl = showAgentControls ? ( |
| 263 | <NativeHeaderToolbar.Menu | 264 | <NativeHeaderToolbar.Menu |
| 264 | identifier="pull-request-agent" | 265 | identifier="pull-request-agent" |
| 265 | width={controlSize} | 266 | width={controlSize} |
apps/mobile/src/features/pull-requests/PullRequestDescription.tsx+50-57| ... | @@ -1,13 +1,20 @@ | ... | @@ -1,13 +1,20 @@ |
| 1 | import { useMemo, useState } from "react"; | 1 | import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown"; |
| 2 | import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from "react-native"; | 2 | import { |
| 3 | PullRequestImageAttachment, | ||
| 4 | PullRequestMedia, | ||
| 5 | PullRequestMediaContext, | ||
| 6 | } from "./PullRequestMedia"; | ||
| 7 | import { PullRequestSectionHeading } from "./PullRequestSectionHeading"; | ||
| 8 | import { useContext, useMemo, useState } from "react"; | ||
| 9 | import { Pressable, ScrollView, StyleSheet, View } from "react-native"; | ||
| 3 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; | 10 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 4 | import { Image } from "expo-image"; | 11 | import { pullRequestImageSource } from "./pullRequestMediaSource"; |
| 5 | import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; | 12 | import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; |
| 6 | import { nativeMarkdownWithHtml } from "@t3tools/mobile-markdown-text/markdown"; | 13 | import { nativeMarkdownWithHtml } from "@t3tools/mobile-markdown-text/markdown"; |
| 7 | import { SymbolView } from "../../components/AppSymbol"; | 14 | import { SymbolView } from "../../components/AppSymbol"; |
| 8 | import { AppText as Text } from "../../components/AppText"; | 15 | import { AppText as Text } from "../../components/AppText"; |
| 9 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 16 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 10 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | 17 | import type { FilePreviewItem } from "../../components/FilePreviewModal"; |
| 11 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; | 18 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; |
| 12 | import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; | 19 | import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; |
| 13 | 20 | ||
| ... | @@ -21,31 +28,33 @@ export function PullRequestDescription(props: { | ... | @@ -21,31 +28,33 @@ export function PullRequestDescription(props: { |
| 21 | }) { | 28 | }) { |
| 22 | const { expanded, onExpandedChange } = props; | 29 | const { expanded, onExpandedChange } = props; |
| 23 | const [bodyHeight, setBodyHeight] = useState(0); | 30 | const [bodyHeight, setBodyHeight] = useState(0); |
| 24 | const [preview, setPreview] = useState<FilePreviewSource | null>(null); | 31 | const mediaContext = useContext(PullRequestMediaContext); |
| 25 | const openingUri = preview && "uri" in preview ? preview.uri : null; | ||
| 26 | const { themeVariables: colors } = useAppearancePreferences(); | 32 | const { themeVariables: colors } = useAppearancePreferences(); |
| 27 | const content = useMemo(() => { | 33 | const content = useMemo(() => { |
| 28 | const document = nativeMarkdownWithHtml( | 34 | const document = nativeMarkdownWithHtml( |
| 29 | parseMarkdownWithOptions(props.markdown, { gfm: true, html: true, math: false }), | 35 | parseMarkdownWithOptions(props.markdown, { gfm: true, html: true, math: false }), |
| 30 | (markdown) => parseMarkdownWithOptions(markdown, { gfm: true, html: false, math: false }), | 36 | (markdown) => parseMarkdownWithOptions(markdown, { gfm: true, html: false, math: false }), |
| 31 | ); | 37 | ); |
| 32 | return pullRequestDescriptionContent(document); | 38 | return pullRequestDescriptionContent(document, splitPullRequestBody(props.markdown)); |
| 33 | }, [props.markdown]); | 39 | }, [props.markdown]); |
| 34 | const gallery = useMemo( | 40 | const gallery = useMemo<FilePreviewItem[]>( |
| 35 | () => | 41 | () => |
| 36 | content.attachments.map((attachment) => ({ | 42 | content.attachments |
| 37 | kind: "image" as const, | 43 | .filter((attachment) => attachment.kind === "image") |
| 38 | uri: attachment.uri, | 44 | .map((attachment) => { |
| 39 | name: new URL(attachment.uri).pathname.split("/").pop() || "Image", | 45 | return { |
| 40 | })), | 46 | ...pullRequestImageSource(attachment.uri, mediaContext), |
| 41 | [content], | 47 | name: new URL(attachment.uri).pathname.split("/").pop() || "Image", |
| 48 | }; | ||
| 49 | }), | ||
| 50 | [content, mediaContext], | ||
| 42 | ); | 51 | ); |
| 43 | const canCollapse = bodyHeight > COLLAPSED_HEIGHT; | 52 | const canCollapse = bodyHeight > COLLAPSED_HEIGHT; |
| 44 | const collapsed = canCollapse && !expanded; | 53 | const collapsed = canCollapse && !expanded; |
| 45 | return ( | 54 | return ( |
| 46 | <View className="gap-5"> | 55 | <View className="gap-5"> |
| 47 | <View className="gap-3"> | 56 | <View className="gap-3"> |
| 48 | <Text className="text-base font-t3-semibold">Description</Text> | 57 | <PullRequestSectionHeading>Description</PullRequestSectionHeading> |
| 49 | {props.markdown.trim() ? ( | 58 | {props.markdown.trim() ? ( |
| 50 | <View className="overflow-hidden"> | 59 | <View className="overflow-hidden"> |
| 51 | <View | 60 | <View |
| ... | @@ -130,7 +139,7 @@ export function PullRequestDescription(props: { | ... | @@ -130,7 +139,7 @@ export function PullRequestDescription(props: { |
| 130 | </View> | 139 | </View> |
| 131 | {content.attachments.length ? ( | 140 | {content.attachments.length ? ( |
| 132 | <View className="gap-3"> | 141 | <View className="gap-3"> |
| 133 | <Text className="text-base font-t3-semibold">Attachments</Text> | 142 | <PullRequestSectionHeading>Attachments</PullRequestSectionHeading> |
| 134 | <ScrollView | 143 | <ScrollView |
| 135 | horizontal | 144 | horizontal |
| 136 | keyboardShouldPersistTaps="always" | 145 | keyboardShouldPersistTaps="always" |
| ... | @@ -138,50 +147,34 @@ export function PullRequestDescription(props: { | ... | @@ -138,50 +147,34 @@ export function PullRequestDescription(props: { |
| 138 | showsHorizontalScrollIndicator={false} | 147 | showsHorizontalScrollIndicator={false} |
| 139 | contentContainerStyle={{ gap: 12 }} | 148 | contentContainerStyle={{ gap: 12 }} |
| 140 | > | 149 | > |
| 141 | {content.attachments.map((attachment) => ( | 150 | {content.attachments.map((attachment) => |
| 142 | <Pressable | 151 | attachment.kind !== "image" ? ( |
| 143 | key={attachment.uri} | 152 | <PullRequestMedia |
| 144 | accessibilityRole="imagebutton" | 153 | key={attachment.uri} |
| 145 | accessibilityLabel={`View attachment: ${attachment.caption}`} | 154 | url={attachment.uri} |
| 146 | accessibilityState={{ busy: openingUri === attachment.uri }} | 155 | kind={attachment.kind} |
| 147 | disabled={preview !== null} | 156 | caption={attachment.caption} |
| 148 | onPress={() => | 157 | compact |
| 149 | setPreview({ | 158 | /> |
| 150 | kind: "image", | 159 | ) : ( |
| 151 | uri: attachment.uri, | 160 | <PullRequestImageAttachment |
| 152 | name: gallery.find((item) => item.uri === attachment.uri)?.name ?? "Image", | 161 | key={attachment.uri} |
| 153 | gallery, | 162 | url={attachment.uri} |
| 154 | }) | 163 | caption={attachment.caption} |
| 155 | } | 164 | source={ |
| 156 | className="gap-2 active:opacity-70" | 165 | gallery[ |
| 157 | style={{ width: 176 }} | 166 | content.attachments |
| 158 | > | 167 | .filter((item) => item.kind === "image") |
| 159 | <View className="overflow-hidden rounded-2xl border border-border-subtle bg-grouped-card"> | 168 | .indexOf(attachment) |
| 160 | <Image | 169 | ]! |
| 161 | source={{ uri: attachment.uri }} | 170 | } |
| 162 | style={{ width: 174, height: 112 }} | 171 | gallery={gallery} |
| 163 | contentFit="contain" | 172 | /> |
| 164 | cachePolicy="memory-disk" | 173 | ), |
| 165 | /> | 174 | )} |
| 166 | {openingUri === attachment.uri ? ( | ||
| 167 | <View | ||
| 168 | pointerEvents="none" | ||
| 169 | style={StyleSheet.absoluteFill} | ||
| 170 | className="items-center justify-center bg-black/30" | ||
| 171 | > | ||
| 172 | <ActivityIndicator color="white" /> | ||
| 173 | </View> | ||
| 174 | ) : null} | ||
| 175 | </View> | ||
| 176 | <Text numberOfLines={1} className="text-xs text-foreground-muted"> | ||
| 177 | {attachment.caption} | ||
| 178 | </Text> | ||
| 179 | </Pressable> | ||
| 180 | ))} | ||
| 181 | </ScrollView> | 175 | </ScrollView> |
| 182 | </View> | 176 | </View> |
| 183 | ) : null} | 177 | ) : null} |
| 184 | <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} /> | ||
| 185 | </View> | 178 | </View> |
| 186 | ); | 179 | ); |
| 187 | } | 180 | } |
apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx+1| ... | @@ -118,6 +118,7 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss | ... | @@ -118,6 +118,7 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss |
| 118 | const resolveLabel = thread?.isResolved ? "Unresolve" : "Resolve"; | 118 | const resolveLabel = thread?.isResolved ? "Unresolve" : "Resolve"; |
| 119 | return ( | 119 | return ( |
| 120 | <PullRequestMarkdownRepository | 120 | <PullRequestMarkdownRepository |
| 121 | params={params} | ||
| 121 | value={detail.data?.provider === "github" ? detail.data.url : null} | 122 | value={detail.data?.provider === "github" ? detail.data.url : null} |
| 122 | > | 123 | > |
| 123 | <KeyboardAvoidingView automaticOffset behavior="height" className="flex-1 bg-screen"> | 124 | <KeyboardAvoidingView automaticOffset behavior="height" className="flex-1 bg-screen"> |
apps/mobile/src/features/pull-requests/PullRequestFiles.tsx+12-11| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { PullRequestSectionHeading } from "./PullRequestSectionHeading"; | ||
| 1 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; | 2 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; |
| 2 | import Animated from "react-native-reanimated"; | 3 | import Animated from "react-native-reanimated"; |
| 3 | import { PR_ROW_LAYOUT, PR_ROW_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations"; | 4 | import { PR_ROW_LAYOUT, PR_ROW_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations"; |
| ... | @@ -167,15 +168,15 @@ export function usePullRequestFilesContent(props: { | ... | @@ -167,15 +168,15 @@ export function usePullRequestFilesContent(props: { |
| 167 | accessibilityState={{ busy: true }} | 168 | accessibilityState={{ busy: true }} |
| 168 | className="min-h-17 flex-row items-center gap-2 py-3" | 169 | className="min-h-17 flex-row items-center gap-2 py-3" |
| 169 | > | 170 | > |
| 170 | <View className="min-w-0 flex-1 gap-2"> | 171 | <View className="min-w-0 flex-1 gap-1"> |
| 171 | <Text className="text-base font-t3-semibold"> | 172 | <PullRequestSectionHeading>Changed files</PullRequestSectionHeading> |
| 172 | {fileCount === undefined | 173 | {fileCount !== undefined ? ( |
| 173 | ? "Changed files" | 174 | <Text className="text-2xs text-foreground-muted"> |
| 174 | : `${fileCount} changed ${fileCount === 1 ? "file" : "files"}`} | 175 | {fileCount} {fileCount === 1 ? "file" : "files"} |
| 175 | </Text> | 176 | </Text> |
| 176 | {filter.hideTestFiles ? ( | 177 | ) : ( |
| 177 | <View className="h-2.5 w-28 rounded bg-subtle-strong" /> | 178 | <View className="h-2.5 w-28 rounded bg-subtle-strong" /> |
| 178 | ) : null} | 179 | )} |
| 179 | </View> | 180 | </View> |
| 180 | {summary?.additions !== undefined && summary.deletions !== undefined ? ( | 181 | {summary?.additions !== undefined && summary.deletions !== undefined ? ( |
| 181 | <PullRequestDiffStats | 182 | <PullRequestDiffStats |
| ... | @@ -196,14 +197,14 @@ export function usePullRequestFilesContent(props: { | ... | @@ -196,14 +197,14 @@ export function usePullRequestFilesContent(props: { |
| 196 | <> | 197 | <> |
| 197 | <View className="min-h-17 flex-row items-center gap-2 py-3"> | 198 | <View className="min-h-17 flex-row items-center gap-2 py-3"> |
| 198 | <View className="min-w-0 flex-1 gap-1"> | 199 | <View className="min-w-0 flex-1 gap-1"> |
| 199 | <Text className="text-base font-t3-semibold"> | 200 | <PullRequestSectionHeading>Changed files</PullRequestSectionHeading> |
| 201 | <Text className="text-2xs text-foreground-muted"> | ||
| 200 | {parsed.fileCount} {filter.hiddenCount ? "visible" : "changed"}{" "} | 202 | {parsed.fileCount} {filter.hiddenCount ? "visible" : "changed"}{" "} |
| 201 | {parsed.fileCount === 1 ? "file" : "files"} | 203 | {parsed.fileCount === 1 ? "file" : "files"} |
| 202 | {viewed.supported | 204 | {viewed.supported |
| 203 | ? ` · ${parsed.files.filter((file) => viewed.isViewed(file.path)).length} viewed` | 205 | ? ` · ${parsed.files.filter((file) => viewed.isViewed(file.path)).length} viewed` |
| 204 | : ""} | 206 | : ""} |
| 205 | </Text> | 207 | {" · "} |
| 206 | <Text className="text-2xs text-foreground-muted"> | ||
| 207 | {filter.hideTestFiles | 208 | {filter.hideTestFiles |
| 208 | ? `${filter.hiddenCount} test ${filter.hiddenCount === 1 ? "file" : "files"} hidden` | 209 | ? `${filter.hiddenCount} test ${filter.hiddenCount === 1 ? "file" : "files"} hidden` |
| 209 | : "Test files included"} | 210 | : "Test files included"} |
apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx+90-15| ... | @@ -1,3 +1,13 @@ | ... | @@ -1,3 +1,13 @@ |
| 1 | import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown"; | ||
| 2 | import type { ReactNode } from "react"; | ||
| 3 | import { View } from "react-native"; | ||
| 4 | import { useProject } from "../../state/entities"; | ||
| 5 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; | ||
| 6 | import { | ||
| 7 | PullRequestMedia, | ||
| 8 | PullRequestMediaContext, | ||
| 9 | usePullRequestMediaUrl, | ||
| 10 | } from "./PullRequestMedia"; | ||
| 1 | import { videoMimeType } from "@t3tools/shared/video"; | 11 | import { videoMimeType } from "@t3tools/shared/video"; |
| 2 | import { createContext, useContext, useCallback, useMemo, useState } from "react"; | 12 | import { createContext, useContext, useCallback, useMemo, useState } from "react"; |
| 3 | import { githubMarkdownLinks } from "@t3tools/shared/githubMarkdownLinks"; | 13 | import { githubMarkdownLinks } from "@t3tools/shared/githubMarkdownLinks"; |
| ... | @@ -16,7 +26,8 @@ import { | ... | @@ -16,7 +26,8 @@ import { |
| 16 | } from "../../native/SelectableMarkdownText"; | 26 | } from "../../native/SelectableMarkdownText"; |
| 17 | import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; | 27 | import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; |
| 18 | import { markdownImageSources } from "../../lib/markdownImageSources"; | 28 | import { markdownImageSources } from "../../lib/markdownImageSources"; |
| 19 | import { FilePreviewGallery } from "../../components/FilePreviewGallery"; | 29 | import { FilePreviewGallery, useFilePreviewGallery } from "../../components/FilePreviewGallery"; |
| 30 | import { pullRequestImageSource } from "./pullRequestMediaSource"; | ||
| 20 | import { | 31 | import { |
| 21 | FilePreviewModal, | 32 | FilePreviewModal, |
| 22 | type FilePreviewSource, | 33 | type FilePreviewSource, |
| ... | @@ -25,7 +36,78 @@ import { | ... | @@ -25,7 +36,78 @@ import { |
| 25 | import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage"; | 36 | import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage"; |
| 26 | import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; | 37 | import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; |
| 27 | 38 | ||
| 28 | export const PullRequestMarkdownRepository = createContext<string | null>(null); | 39 | const RepositoryContext = createContext<string | null>(null); |
| 40 | |||
| 41 | export function PullRequestMarkdownRepository(props: { | ||
| 42 | readonly value: string | null; | ||
| 43 | readonly params: PullRequestParams; | ||
| 44 | readonly children: ReactNode; | ||
| 45 | }) { | ||
| 46 | const target = pullRequestTarget(props.params); | ||
| 47 | const project = useProject({ | ||
| 48 | environmentId: target.environmentId, | ||
| 49 | projectId: target.input.projectId, | ||
| 50 | }); | ||
| 51 | const cwd = project?.workspaceRoot; | ||
| 52 | const media = useMemo( | ||
| 53 | () => (cwd ? { environmentId: target.environmentId, cwd } : null), | ||
| 54 | [target.environmentId, cwd], | ||
| 55 | ); | ||
| 56 | return ( | ||
| 57 | <RepositoryContext value={props.value}> | ||
| 58 | <PullRequestMediaContext value={media}>{props.children}</PullRequestMediaContext> | ||
| 59 | </RepositoryContext> | ||
| 60 | ); | ||
| 61 | } | ||
| 62 | |||
| 63 | function MarkdownImage(props: { | ||
| 64 | readonly href: string; | ||
| 65 | readonly alt: string | null; | ||
| 66 | readonly onPreview: (source: FilePreviewSource) => void; | ||
| 67 | }) { | ||
| 68 | const media = usePullRequestMediaUrl(props.href); | ||
| 69 | const context = useContext(PullRequestMediaContext); | ||
| 70 | const withGallery = useFilePreviewGallery(); | ||
| 71 | return ( | ||
| 72 | <ThreadMarkdownImageView | ||
| 73 | uri={media.uri} | ||
| 74 | sourceKey={props.href} | ||
| 75 | unavailable={media.unavailable || !/^https?:\/\//i.test(props.href)} | ||
| 76 | alt={props.alt} | ||
| 77 | onPressPreview={(source) => | ||
| 78 | props.onPreview( | ||
| 79 | withGallery({ | ||
| 80 | ...pullRequestImageSource(props.href, context), | ||
| 81 | name: source.name, | ||
| 82 | sourceIdentifier: source.sourceIdentifier, | ||
| 83 | }), | ||
| 84 | ) | ||
| 85 | } | ||
| 86 | /> | ||
| 87 | ); | ||
| 88 | } | ||
| 89 | |||
| 90 | export function PullRequestMarkdown(props: { | ||
| 91 | readonly markdown: string; | ||
| 92 | readonly imageGroupMarkdown?: string; | ||
| 93 | }) { | ||
| 94 | const segments = useMemo(() => splitPullRequestBody(props.markdown), [props.markdown]); | ||
| 95 | return ( | ||
| 96 | <View style={{ gap: 12 }}> | ||
| 97 | {segments.map((segment) => | ||
| 98 | segment.kind === "markdown" ? ( | ||
| 99 | <PullRequestMarkdownContent | ||
| 100 | key={segment.id} | ||
| 101 | markdown={segment.text} | ||
| 102 | imageGroupMarkdown={props.imageGroupMarkdown ?? props.markdown} | ||
| 103 | /> | ||
| 104 | ) : ( | ||
| 105 | <PullRequestMedia key={segment.id} url={segment.url} kind={segment.media} /> | ||
| 106 | ), | ||
| 107 | )} | ||
| 108 | </View> | ||
| 109 | ); | ||
| 110 | } | ||
| 29 | 111 | ||
| 30 | function renderLink( | 112 | function renderLink( |
| 31 | { href, children }: CustomRendererProps, | 113 | { href, children }: CustomRendererProps, |
| ... | @@ -61,11 +143,12 @@ function renderHtmlBlock( | ... | @@ -61,11 +143,12 @@ function renderHtmlBlock( |
| 61 | ) : null; | 143 | ) : null; |
| 62 | } | 144 | } |
| 63 | 145 | ||
| 64 | export function PullRequestMarkdown(props: { | 146 | function PullRequestMarkdownContent(props: { |
| 65 | readonly markdown: string; | 147 | readonly markdown: string; |
| 66 | readonly imageGroupMarkdown?: string; | 148 | readonly imageGroupMarkdown?: string; |
| 67 | }) { | 149 | }) { |
| 68 | const repositoryUrl = useContext(PullRequestMarkdownRepository); | 150 | const repositoryUrl = useContext(RepositoryContext); |
| 151 | const mediaContext = useContext(PullRequestMediaContext); | ||
| 69 | const markdown = useMemo( | 152 | const markdown = useMemo( |
| 70 | () => githubMarkdownLinks(props.markdown, repositoryUrl), | 153 | () => githubMarkdownLinks(props.markdown, repositoryUrl), |
| 71 | [props.markdown, repositoryUrl], | 154 | [props.markdown, repositoryUrl], |
| ... | @@ -85,20 +168,12 @@ export function PullRequestMarkdown(props: { | ... | @@ -85,20 +168,12 @@ export function PullRequestMarkdown(props: { |
| 85 | /^https?:\/\//i.test(uri) && | 168 | /^https?:\/\//i.test(uri) && |
| 86 | videoMimeType({ name: uri.split(/[?#]/)[0] ?? uri, mimeType: "" }) === null, | 169 | videoMimeType({ name: uri.split(/[?#]/)[0] ?? uri, mimeType: "" }) === null, |
| 87 | ) | 170 | ) |
| 88 | .map((uri) => ({ kind: "image", uri })), | 171 | .map((uri) => pullRequestImageSource(uri, mediaContext)), |
| 89 | [props.markdown, props.imageGroupMarkdown], | 172 | [props.markdown, props.imageGroupMarkdown, mediaContext], |
| 90 | ); | 173 | ); |
| 91 | const [preview, setPreview] = useState<FilePreviewSource | null>(null); | 174 | const [preview, setPreview] = useState<FilePreviewSource | null>(null); |
| 92 | const renderImage = useCallback<MarkdownImageRenderer>( | 175 | const renderImage = useCallback<MarkdownImageRenderer>( |
| 93 | (image) => ( | 176 | (image) => <MarkdownImage href={image.href} alt={image.alt} onPreview={setPreview} />, |
| 94 | <ThreadMarkdownImageView | ||
| 95 | uri={/^https?:\/\//i.test(image.href) ? image.href : null} | ||
| 96 | sourceKey={image.href} | ||
| 97 | unavailable={!/^https?:\/\//i.test(image.href)} | ||
| 98 | alt={image.alt} | ||
| 99 | onPressPreview={setPreview} | ||
| 100 | /> | ||
| 101 | ), | ||
| 102 | [], | 177 | [], |
| 103 | ); | 178 | ); |
| 104 | const styles = useMarkdownPreviewStyles(renderImage); | 179 | const styles = useMarkdownPreviewStyles(renderImage); |
apps/mobile/src/features/pull-requests/PullRequestMedia.tsx created+142| ... | @@ -0,0 +1,142 @@ | ||
| 1 | import { createContext, useContext, useMemo, useState } from "react"; | ||
| 2 | import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource"; | ||
| 3 | import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; | ||
| 4 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | ||
| 5 | import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; | ||
| 6 | import { videoMimeType } from "@t3tools/shared/video"; | ||
| 7 | import { Pressable, View } from "react-native"; | ||
| 8 | import { Image } from "expo-image"; | ||
| 9 | import { AppText } from "../../components/AppText"; | ||
| 10 | import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; | ||
| 11 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; | ||
| 12 | import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; | ||
| 13 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; | ||
| 14 | import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage"; | ||
| 15 | import { | ||
| 16 | FilePreviewModal, | ||
| 17 | type FilePreviewSource, | ||
| 18 | type FilePreviewItem, | ||
| 19 | } from "../../components/FilePreviewModal"; | ||
| 20 | |||
| 21 | export const PullRequestMediaContext = createContext<{ | ||
| 22 | readonly environmentId: EnvironmentId; | ||
| 23 | readonly cwd: string; | ||
| 24 | } | null>(null); | ||
| 25 | |||
| 26 | /** Only GitHub-hosted media needs the environment's authenticated asset path. */ | ||
| 27 | export function usePullRequestMediaUrl(url: string) { | ||
| 28 | const context = useContext(PullRequestMediaContext); | ||
| 29 | const fetchUrl = githubMediaFetchUrl(url); | ||
| 30 | const resource = useMemo<AssetResource | null>( | ||
| 31 | () => (context && fetchUrl ? { _tag: "github-media", cwd: context.cwd, url: fetchUrl } : null), | ||
| 32 | [context, fetchUrl], | ||
| 33 | ); | ||
| 34 | const asset = useAssetUrlState(context?.environmentId ?? null, resource); | ||
| 35 | const refresh = useRefreshAssetUrl(context?.environmentId ?? null, resource); | ||
| 36 | return { | ||
| 37 | uri: asset._tag === "Success" ? asset.url : resource ? null : url, | ||
| 38 | unavailable: resource !== null && asset._tag === "Failure", | ||
| 39 | source: pullRequestMediaSource(url, context), | ||
| 40 | resolvePlaybackUri: resource ? async () => (await refresh()) ?? null : undefined, | ||
| 41 | }; | ||
| 42 | } | ||
| 43 | |||
| 44 | export function PullRequestMedia(props: { | ||
| 45 | readonly url: string; | ||
| 46 | readonly kind: "image" | "video" | "unknown"; | ||
| 47 | readonly caption?: string; | ||
| 48 | readonly compact?: boolean; | ||
| 49 | }) { | ||
| 50 | const media = usePullRequestMediaUrl(props.url); | ||
| 51 | const context = useContext(PullRequestMediaContext); | ||
| 52 | const withGallery = useFilePreviewGallery(); | ||
| 53 | const [image, setImage] = useState<FilePreviewSource | null>(null); | ||
| 54 | const [video, setVideo] = useState<VideoPreviewSource | null>(null); | ||
| 55 | const name = props.caption || new URL(props.url).pathname.split("/").pop() || "Attachment"; | ||
| 56 | if (props.kind === "image") | ||
| 57 | return ( | ||
| 58 | <> | ||
| 59 | <ThreadMarkdownImageView | ||
| 60 | uri={media.uri} | ||
| 61 | sourceKey={props.url} | ||
| 62 | unavailable={media.unavailable} | ||
| 63 | alt={name} | ||
| 64 | onPressPreview={(source) => | ||
| 65 | setImage( | ||
| 66 | withGallery({ | ||
| 67 | ...pullRequestImageSource(props.url, context), | ||
| 68 | name, | ||
| 69 | sourceIdentifier: source.sourceIdentifier, | ||
| 70 | }), | ||
| 71 | ) | ||
| 72 | } | ||
| 73 | /> | ||
| 74 | <FilePreviewModal source={image} onRequestClose={() => setImage(null)} /> | ||
| 75 | </> | ||
| 76 | ); | ||
| 77 | return ( | ||
| 78 | <View style={props.compact ? { width: 176 } : undefined}> | ||
| 79 | {props.compact ? ( | ||
| 80 | <VideoAttachmentTile | ||
| 81 | name={name} | ||
| 82 | sourceIdentifier={props.url} | ||
| 83 | thumbnailSource={media.uri} | ||
| 84 | style={{ width: 176, height: 112, borderRadius: 16 }} | ||
| 85 | onPress={() => | ||
| 86 | setVideo({ | ||
| 87 | type: "media", | ||
| 88 | ...media.source, | ||
| 89 | name, | ||
| 90 | mimeType: | ||
| 91 | videoMimeType({ name: props.url.split(/[?#]/)[0] ?? props.url, mimeType: "" }) ?? | ||
| 92 | "video/mp4", | ||
| 93 | }) | ||
| 94 | } | ||
| 95 | /> | ||
| 96 | ) : ( | ||
| 97 | <MediaVideoPlayer | ||
| 98 | uri={media.uri} | ||
| 99 | unavailable={media.unavailable} | ||
| 100 | name={name} | ||
| 101 | thumbnailKey={props.url} | ||
| 102 | resolvePlaybackUri={media.resolvePlaybackUri} | ||
| 103 | /> | ||
| 104 | )} | ||
| 105 | <VideoPreviewModal source={video} onRequestClose={() => setVideo(null)} /> | ||
| 106 | </View> | ||
| 107 | ); | ||
| 108 | } | ||
| 109 | |||
| 110 | export function PullRequestImageAttachment(props: { | ||
| 111 | readonly url: string; | ||
| 112 | readonly caption: string; | ||
| 113 | readonly source: FilePreviewItem; | ||
| 114 | readonly gallery: readonly FilePreviewItem[]; | ||
| 115 | }) { | ||
| 116 | const media = usePullRequestMediaUrl(props.url); | ||
| 117 | const [preview, setPreview] = useState<FilePreviewSource | null>(null); | ||
| 118 | return ( | ||
| 119 | <> | ||
| 120 | <Pressable | ||
| 121 | accessibilityRole="imagebutton" | ||
| 122 | accessibilityLabel={`View attachment: ${props.caption}`} | ||
| 123 | onPress={() => setPreview({ ...props.source, gallery: props.gallery })} | ||
| 124 | className="gap-2 active:opacity-70" | ||
| 125 | style={{ width: 176 }} | ||
| 126 | > | ||
| 127 | <View className="overflow-hidden rounded-2xl border border-border-subtle bg-grouped-card"> | ||
| 128 | <Image | ||
| 129 | source={media.uri ? { uri: media.uri } : null} | ||
| 130 | style={{ width: 174, height: 112 }} | ||
| 131 | contentFit="contain" | ||
| 132 | cachePolicy="memory-disk" | ||
| 133 | /> | ||
| 134 | </View> | ||
| 135 | <AppText numberOfLines={1} className="text-xs text-foreground-muted"> | ||
| 136 | {props.caption} | ||
| 137 | </AppText> | ||
| 138 | </Pressable> | ||
| 139 | <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} /> | ||
| 140 | </> | ||
| 141 | ); | ||
| 142 | } | ||
apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx+8-4| ... | @@ -54,9 +54,12 @@ export function PullRequestMergeControls(props: { | ... | @@ -54,9 +54,12 @@ export function PullRequestMergeControls(props: { |
| 54 | hasStack: stack.stack !== null, | 54 | hasStack: stack.stack !== null, |
| 55 | isFresh: stack.isFresh, | 55 | isFresh: stack.isFresh, |
| 56 | }); | 56 | }); |
| 57 | const checkingStack = stack.supportsStackActions && !stack.isFresh && !stack.error; | 57 | const checkingStack = |
| 58 | permissions.connected && stack.supportsStackActions && !stack.isFresh && !stack.error; | ||
| 58 | const reason = | 59 | const reason = |
| 59 | mergeUnavailableReason(pr, permissions.canWrite) ?? | 60 | (!permissions.connected |
| 61 | ? "Reconnect to merge." | ||
| 62 | : mergeUnavailableReason(pr, permissions.canWrite)) ?? | ||
| 60 | (stack.stack | 63 | (stack.stack |
| 61 | ? !stack.supportsStackActions | 64 | ? !stack.supportsStackActions |
| 62 | ? "This environment can display stacks but cannot merge them." | 65 | ? "This environment can display stacks but cannot merge them." |
| ... | @@ -185,7 +188,7 @@ export function PullRequestMergeControls(props: { | ... | @@ -185,7 +188,7 @@ export function PullRequestMergeControls(props: { |
| 185 | <Text | 188 | <Text |
| 186 | className={`shrink text-center text-sm font-t3-semibold ${canMerge ? "text-screen" : "text-foreground-muted"}`} | 189 | className={`shrink text-center text-sm font-t3-semibold ${canMerge ? "text-screen" : "text-foreground-muted"}`} |
| 187 | > | 190 | > |
| 188 | {busy ? "Working…" : label} | 191 | {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label} |
| 189 | </Text> | 192 | </Text> |
| 190 | </Pressable> | 193 | </Pressable> |
| 191 | {methods.length > 0 || canAutoMerge ? ( | 194 | {methods.length > 0 || canAutoMerge ? ( |
| ... | @@ -230,7 +233,8 @@ export function PullRequestMergeControls(props: { | ... | @@ -230,7 +233,8 @@ export function PullRequestMergeControls(props: { |
| 230 | </ControlPillMenu> | 233 | </ControlPillMenu> |
| 231 | ) : null} | 234 | ) : null} |
| 232 | </View> | 235 | </View> |
| 233 | {reason && | 236 | {permissions.connected && |
| 237 | reason && | ||
| 234 | reason !== MERGE_CONFLICT_REASON && | 238 | reason !== MERGE_CONFLICT_REASON && |
| 235 | reason !== "Checking stack membership…" && | 239 | reason !== "Checking stack membership…" && |
| 236 | !(checkingStack && reason === "Refresh the stack before merging.") ? ( | 240 | !(checkingStack && reason === "Refresh the stack before merging.") ? ( |
apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx+2-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { PullRequestSectionHeading } from "./PullRequestSectionHeading"; | ||
| 1 | import type { PullRequestListEntry } from "@t3tools/contracts"; | 2 | import type { PullRequestListEntry } from "@t3tools/contracts"; |
| 2 | import { View } from "react-native"; | 3 | import { View } from "react-native"; |
| 3 | import { AppText as Text } from "../../components/AppText"; | 4 | import { AppText as Text } from "../../components/AppText"; |
| ... | @@ -97,7 +98,7 @@ export function PullRequestOverviewLoading({ | ... | @@ -97,7 +98,7 @@ export function PullRequestOverviewLoading({ |
| 97 | </View> | 98 | </View> |
| 98 | ) : null} | 99 | ) : null} |
| 99 | <View className="gap-3"> | 100 | <View className="gap-3"> |
| 100 | <Text className="text-base font-t3-semibold">Description</Text> | 101 | <PullRequestSectionHeading>Description</PullRequestSectionHeading> |
| 101 | <View className="gap-3 py-1"> | 102 | <View className="gap-3 py-1"> |
| 102 | <View className="h-4 w-full rounded bg-subtle-strong" /> | 103 | <View className="h-4 w-full rounded bg-subtle-strong" /> |
| 103 | <View className="h-4 w-full rounded bg-subtle-strong" /> | 104 | <View className="h-4 w-full rounded bg-subtle-strong" /> |
apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx+3| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { usePullRequestConnected } from "./usePullRequestConnected"; | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 3 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| ... | @@ -60,7 +61,9 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ | ... | @@ -60,7 +61,9 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ |
| 60 | const canWrite = useAtomValue( | 61 | const canWrite = useAtomValue( |
| 61 | composerPullRequests.requestReviewers.permissionAtom(target.environmentId), | 62 | composerPullRequests.requestReviewers.permissionAtom(target.environmentId), |
| 62 | ); | 63 | ); |
| 64 | const connected = usePullRequestConnected(route.params.environmentId); | ||
| 63 | const allowed = | 65 | const allowed = |
| 66 | connected && | ||
| 64 | canWrite && | 67 | canWrite && |
| 65 | pr?.state === "open" && | 68 | pr?.state === "open" && |
| 66 | pr.capabilities.reviewers.request && | 69 | pr.capabilities.reviewers.request && |
apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx+10-3| ... | @@ -62,6 +62,7 @@ import { SegmentedControl } from "../../components/SegmentedControl"; | ... | @@ -62,6 +62,7 @@ import { SegmentedControl } from "../../components/SegmentedControl"; |
| 62 | import { SymbolView } from "../../components/AppSymbol"; | 62 | import { SymbolView } from "../../components/AppSymbol"; |
| 63 | import { relativeTime } from "../../lib/time"; | 63 | import { relativeTime } from "../../lib/time"; |
| 64 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 64 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 65 | import { usePullRequestConnected } from "./usePullRequestConnected"; | ||
| 65 | import { composerPullRequests } from "../../state/pull-requests"; | 66 | import { composerPullRequests } from "../../state/pull-requests"; |
| 66 | import { useEnvironmentQuery } from "../../state/query"; | 67 | import { useEnvironmentQuery } from "../../state/query"; |
| 67 | import { usePullToRefresh } from "../../state/use-pull-to-refresh"; | 68 | import { usePullToRefresh } from "../../state/use-pull-to-refresh"; |
| ... | @@ -272,6 +273,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP | ... | @@ -272,6 +273,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 272 | const navigation = useNavigation(); | 273 | const navigation = useNavigation(); |
| 273 | const insets = useSafeAreaInsets(); | 274 | const insets = useSafeAreaInsets(); |
| 274 | const params = route.params; | 275 | const params = route.params; |
| 276 | const connected = usePullRequestConnected(params.environmentId); | ||
| 275 | const [section, setSection] = useState<"overview" | "activity" | "guide">("overview"); | 277 | const [section, setSection] = useState<"overview" | "activity" | "guide">("overview"); |
| 276 | // Keep each tab's native list and measured rows alive when it is hidden. | 278 | // Keep each tab's native list and measured rows alive when it is hidden. |
| 277 | const requestedSection = useRef(section); | 279 | const requestedSection = useRef(section); |
| ... | @@ -350,18 +352,23 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP | ... | @@ -350,18 +352,23 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 350 | } | 352 | } |
| 351 | }; | 353 | }; |
| 352 | return ( | 354 | return ( |
| 353 | <PullRequestMarkdownRepository value={pr?.provider === "github" ? pr.url : null}> | 355 | <PullRequestMarkdownRepository |
| 356 | params={params} | ||
| 357 | value={pr?.provider === "github" ? pr.url : null} | ||
| 358 | > | ||
| 354 | <View className="flex-1 bg-screen"> | 359 | <View className="flex-1 bg-screen"> |
| 355 | <NativeStackScreenOptions | 360 | <NativeStackScreenOptions |
| 356 | options={{ | 361 | options={{ |
| 357 | title: `#${params.number}`, | 362 | title: `#${params.number}`, |
| 358 | headerTitle: `#${params.number}`, | 363 | headerTitle: `#${params.number}`, |
| 359 | unstable_headerSubtitle: params.repository, | 364 | unstable_headerSubtitle: connected |
| 365 | ? params.repository | ||
| 366 | : `${params.repository} · Offline`, | ||
| 360 | }} | 367 | }} |
| 361 | /> | 368 | /> |
| 362 | {Platform.OS === "android" ? ( | 369 | {Platform.OS === "android" ? ( |
| 363 | <AndroidScreenHeader | 370 | <AndroidScreenHeader |
| 364 | title={`#${params.number}`} | 371 | title={`#${params.number}${connected ? "" : " · Offline"}`} |
| 365 | onBack={() => navigation.goBack()} | 372 | onBack={() => navigation.goBack()} |
| 366 | actions={[ | 373 | actions={[ |
| 367 | ...(permissions.verdicts.length | 374 | ...(permissions.verdicts.length |
apps/mobile/src/features/pull-requests/PullRequestSectionHeading.tsx created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | import type { ReactNode } from "react"; | ||
| 2 | import { AppText } from "../../components/AppText"; | ||
| 3 | |||
| 4 | export function PullRequestSectionHeading({ children }: { readonly children: ReactNode }) { | ||
| 5 | return <AppText className="text-base font-t3-semibold">{children}</AppText>; | ||
| 6 | } | ||
apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx+3| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { usePullRequestConnected } from "./usePullRequestConnected"; | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useNavigation } from "@react-navigation/native"; | 3 | import { useNavigation } from "@react-navigation/native"; |
| 3 | import type { PullRequestAction, PullRequestDetail } from "@t3tools/contracts"; | 4 | import type { PullRequestAction, PullRequestDetail } from "@t3tools/contracts"; |
| ... | @@ -237,7 +238,9 @@ export function PullRequestReviewersRow(props: RowProps) { | ... | @@ -237,7 +238,9 @@ export function PullRequestReviewersRow(props: RowProps) { |
| 237 | pullRequestTarget(props.params).environmentId, | 238 | pullRequestTarget(props.params).environmentId, |
| 238 | ), | 239 | ), |
| 239 | ); | 240 | ); |
| 241 | const connected = usePullRequestConnected(props.params.environmentId); | ||
| 240 | const canRequest = | 242 | const canRequest = |
| 243 | connected && | ||
| 241 | canWrite && | 244 | canWrite && |
| 242 | props.pr.state === "open" && | 245 | props.pr.state === "open" && |
| 243 | props.pr.capabilities.reviewers.request && | 246 | props.pr.capabilities.reviewers.request && |
apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx+21-11| ... | @@ -99,7 +99,6 @@ export function PullRequestsScreen() { | ... | @@ -99,7 +99,6 @@ export function PullRequestsScreen() { |
| 99 | environments | 99 | environments |
| 100 | .filter( | 100 | .filter( |
| 101 | (environment) => | 101 | (environment) => |
| 102 | environment.connectionState === "connected" && | ||
| 103 | configs.get(environment.environmentId)?.environment.capabilities.pullRequests === | 102 | configs.get(environment.environmentId)?.environment.capabilities.pullRequests === |
| 104 | true && | 103 | true && |
| 105 | (environmentId === null || environment.environmentId === environmentId) && | 104 | (environmentId === null || environment.environmentId === environmentId) && |
| ... | @@ -121,8 +120,15 @@ export function PullRequestsScreen() { | ... | @@ -121,8 +120,15 @@ export function PullRequestsScreen() { |
| 121 | [environments, environmentId, configs, controls, query], | 120 | [environments, environmentId, configs, controls, query], |
| 122 | ); | 121 | ); |
| 123 | const result = usePullRequestList(targets); | 122 | const result = usePullRequestList(targets); |
| 123 | const connectedTargets = targets.filter((target) => | ||
| 124 | environments.some( | ||
| 125 | (environment) => | ||
| 126 | environment.environmentId === target.environmentId && | ||
| 127 | environment.connectionState === "connected", | ||
| 128 | ), | ||
| 129 | ); | ||
| 124 | const { actions, select, busyKey } = usePullRequestListActions( | 130 | const { actions, select, busyKey } = usePullRequestListActions( |
| 125 | targets.map((target) => target.environmentId), | 131 | connectedTargets.map((target) => target.environmentId), |
| 126 | result.updateEntry, | 132 | result.updateEntry, |
| 127 | ); | 133 | ); |
| 128 | const selectedKey = primaryColumn?.selectedPullRequestKey; | 134 | const selectedKey = primaryColumn?.selectedPullRequestKey; |
| ... | @@ -309,7 +315,7 @@ export function PullRequestsScreen() { | ... | @@ -309,7 +315,7 @@ export function PullRequestsScreen() { |
| 309 | ) : undefined | 315 | ) : undefined |
| 310 | } | 316 | } |
| 311 | ListEmptyComponent={ | 317 | ListEmptyComponent={ |
| 312 | result.isPending ? ( | 318 | result.isPending && connectedTargets.length > 0 ? ( |
| 313 | <View className="items-center py-12"> | 319 | <View className="items-center py-12"> |
| 314 | <ActivityIndicator colorClassName="accent-icon" /> | 320 | <ActivityIndicator colorClassName="accent-icon" /> |
| 315 | </View> | 321 | </View> |
| ... | @@ -319,17 +325,21 @@ export function PullRequestsScreen() { | ... | @@ -319,17 +325,21 @@ export function PullRequestsScreen() { |
| 319 | title={ | 325 | title={ |
| 320 | result.error | 326 | result.error |
| 321 | ? "Couldn't load pull requests" | 327 | ? "Couldn't load pull requests" |
| 322 | : targets.length | 328 | : targets.length && connectedTargets.length === 0 |
| 323 | ? query | 329 | ? "Environment offline" |
| 324 | ? "No results" | 330 | : targets.length |
| 325 | : "No matching pull requests" | 331 | ? query |
| 326 | : "Connect an environment" | 332 | ? "No results" |
| 333 | : "No matching pull requests" | ||
| 334 | : "Connect an environment" | ||
| 327 | } | 335 | } |
| 328 | detail={ | 336 | detail={ |
| 329 | result.error ?? | 337 | result.error ?? |
| 330 | (targets.length | 338 | (targets.length && connectedTargets.length === 0 |
| 331 | ? "Pull requests from the selected environments' projects appear here." | 339 | ? "Reconnect to load pull requests that aren't saved on this device." |
| 332 | : "Add an environment in Settings to browse its pull requests.") | 340 | : targets.length |
| 341 | ? "Pull requests from the selected environments' projects appear here." | ||
| 342 | : "Add an environment in Settings to browse its pull requests.") | ||
| 333 | } | 343 | } |
| 334 | variant="plain" | 344 | variant="plain" |
| 335 | /> | 345 | /> |
apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx created+85| ... | @@ -0,0 +1,85 @@ | ||
| 1 | import { Pressable, View } from "react-native"; | ||
| 2 | import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated"; | ||
| 3 | import { AppText as Text } from "../../../components/AppText"; | ||
| 4 | import { SymbolView } from "../../../components/AppSymbol"; | ||
| 5 | import { PR_ROW_EXIT, PR_ROW_LAYOUT } from "../pullRequestAnimations"; | ||
| 6 | import { guideNoticeState } from "./reviewGuideState"; | ||
| 7 | import type { useReviewGuide } from "./useReviewGuide"; | ||
| 8 | |||
| 9 | const NOTICE_ENTER = FadeIn.delay(180).duration(120).reduceMotion(ReduceMotion.System); | ||
| 10 | |||
| 11 | /** Keep the action in one place as loading, writing, and retry states change. */ | ||
| 12 | export function GuideNotice({ state }: { readonly state: ReturnType<typeof useReviewGuide> }) { | ||
| 13 | const notice = guideNoticeState(state); | ||
| 14 | if (notice.kind === "ready") return null; | ||
| 15 | const compact = state.data?.guide != null; | ||
| 16 | const failed = notice.kind === "failed" || notice.kind === "unavailable"; | ||
| 17 | const busy = notice.kind === "loading" || notice.kind === "writing"; | ||
| 18 | return ( | ||
| 19 | <Animated.View | ||
| 20 | layout={PR_ROW_LAYOUT} | ||
| 21 | entering={compact ? NOTICE_ENTER : undefined} | ||
| 22 | exiting={PR_ROW_EXIT} | ||
| 23 | className={ | ||
| 24 | compact ? "mb-5 rounded-2xl bg-grouped-card p-4" : "rounded-2xl bg-grouped-card p-5" | ||
| 25 | } | ||
| 26 | accessibilityLiveRegion="polite" | ||
| 27 | accessibilityState={{ busy }} | ||
| 28 | > | ||
| 29 | <View className="flex-row items-center gap-3"> | ||
| 30 | <SymbolView | ||
| 31 | name={failed ? "exclamationmark.circle" : busy ? "clock" : "list.number"} | ||
| 32 | size={19} | ||
| 33 | tintColorClassName={failed ? "accent-danger-foreground" : "accent-icon-muted"} | ||
| 34 | /> | ||
| 35 | <Text className="text-base font-t3-semibold"> | ||
| 36 | {compact ? notice.title : "Review guide"} | ||
| 37 | </Text> | ||
| 38 | </View> | ||
| 39 | <View style={compact ? undefined : { minHeight: 100 }} className="pt-3 pb-4"> | ||
| 40 | <Text className="text-sm leading-6 text-foreground-muted">{notice.description}</Text> | ||
| 41 | </View> | ||
| 42 | <View className="min-h-12 flex-row items-center justify-between gap-3"> | ||
| 43 | {state.canWrite || busy || notice.kind === "unavailable" ? ( | ||
| 44 | <Pressable | ||
| 45 | accessibilityRole="button" | ||
| 46 | accessibilityState={{ disabled: busy, busy }} | ||
| 47 | disabled={busy} | ||
| 48 | style={{ width: 152 }} | ||
| 49 | className={ | ||
| 50 | compact | ||
| 51 | ? "min-h-12 items-center justify-center rounded-full bg-secondary px-4 active:opacity-70" | ||
| 52 | : "min-h-12 items-center justify-center rounded-full bg-foreground px-4 active:opacity-70" | ||
| 53 | } | ||
| 54 | onPress={() => (notice.kind === "unavailable" ? state.refresh() : void state.write())} | ||
| 55 | > | ||
| 56 | <Text | ||
| 57 | className={ | ||
| 58 | compact | ||
| 59 | ? "text-center font-t3-medium text-secondary-foreground" | ||
| 60 | : "text-center font-t3-medium text-screen" | ||
| 61 | } | ||
| 62 | > | ||
| 63 | {notice.action} | ||
| 64 | </Text> | ||
| 65 | </Pressable> | ||
| 66 | ) : ( | ||
| 67 | <Text className="text-xs text-foreground-muted"> | ||
| 68 | {notice.kind === "failed" | ||
| 69 | ? "Reconnect or ask someone with write access to retry." | ||
| 70 | : "Writing a guide requires a connection and write access."} | ||
| 71 | </Text> | ||
| 72 | )} | ||
| 73 | {notice.kind === "writing" && state.data?.run?._tag === "Running" ? ( | ||
| 74 | <Text className="shrink text-xs text-foreground-muted"> | ||
| 75 | Started{" "} | ||
| 76 | {new Date(state.data.run.startedAt).toLocaleTimeString([], { | ||
| 77 | hour: "2-digit", | ||
| 78 | minute: "2-digit", | ||
| 79 | })} | ||
| 80 | </Text> | ||
| 81 | ) : null} | ||
| 82 | </View> | ||
| 83 | </Animated.View> | ||
| 84 | ); | ||
| 85 | } | ||
apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx+32-93| ... | @@ -13,7 +13,6 @@ import { | ... | @@ -13,7 +13,6 @@ import { |
| 13 | import { SymbolView } from "../../../components/AppSymbol"; | 13 | import { SymbolView } from "../../../components/AppSymbol"; |
| 14 | import { GuideOutline } from "./GuideOutline"; | 14 | import { GuideOutline } from "./GuideOutline"; |
| 15 | import { AppText as Text } from "../../../components/AppText"; | 15 | import { AppText as Text } from "../../../components/AppText"; |
| 16 | import { MaterialButton } from "../../../components/MaterialButton"; | ||
| 17 | import { ScreenScrollView } from "../../../components/ScreenScrollView"; | 16 | import { ScreenScrollView } from "../../../components/ScreenScrollView"; |
| 18 | import { ControlPillMenu } from "../../../components/ControlPillMenu"; | 17 | import { ControlPillMenu } from "../../../components/ControlPillMenu"; |
| 19 | import { MaterialIconButton } from "../../../components/MaterialIconButton"; | 18 | import { MaterialIconButton } from "../../../components/MaterialIconButton"; |
| ... | @@ -24,6 +23,9 @@ import { PullRequestMarkdown, PullRequestMarkdownRepository } from "../PullReque | ... | @@ -24,6 +23,9 @@ import { PullRequestMarkdown, PullRequestMarkdownRepository } from "../PullReque |
| 24 | import { usePullRequestDiff, type PullRequestParams } from "../usePullRequestDiff"; | 23 | import { usePullRequestDiff, type PullRequestParams } from "../usePullRequestDiff"; |
| 25 | import { ReviewGuideExcerpt } from "./ReviewGuideExcerpt"; | 24 | import { ReviewGuideExcerpt } from "./ReviewGuideExcerpt"; |
| 26 | import { reviewGuideState } from "./reviewGuideState"; | 25 | import { reviewGuideState } from "./reviewGuideState"; |
| 26 | import { GuideNotice } from "./GuideNotice"; | ||
| 27 | import Animated from "react-native-reanimated"; | ||
| 28 | import { PR_ROW_ENTER, PR_ROW_EXIT, PR_ROW_LAYOUT } from "../pullRequestAnimations"; | ||
| 27 | import { useEnvironmentQuery } from "../../../state/query"; | 29 | import { useEnvironmentQuery } from "../../../state/query"; |
| 28 | import { composerPullRequests } from "../../../state/pull-requests"; | 30 | import { composerPullRequests } from "../../../state/pull-requests"; |
| 29 | import { usePullRequestPermissions } from "../usePullRequestPermissions"; | 31 | import { usePullRequestPermissions } from "../usePullRequestPermissions"; |
| ... | @@ -44,111 +46,47 @@ const attentionLabels = { | ... | @@ -44,111 +46,47 @@ const attentionLabels = { |
| 44 | accessibility: "An accessibility call", | 46 | accessibility: "An accessibility call", |
| 45 | }; | 47 | }; |
| 46 | 48 | ||
| 47 | function GuideNotice({ state }: { readonly state: ReturnType<typeof useReviewGuide> }) { | ||
| 48 | const { guide, stale, failure } = reviewGuideState(state.data); | ||
| 49 | const error = state.startError ?? failure ?? state.error; | ||
| 50 | const label = state.running | ||
| 51 | ? guide | ||
| 52 | ? "Rewriting review guide" | ||
| 53 | : "Writing review guide" | ||
| 54 | : error | ||
| 55 | ? "Could not write guide" | ||
| 56 | : stale | ||
| 57 | ? "Guide is out of date" | ||
| 58 | : "No review guide yet"; | ||
| 59 | if (guide && !stale && !error && !state.running) return null; | ||
| 60 | return ( | ||
| 61 | <View | ||
| 62 | className={guide ? "gap-3 border-l-2 border-border pl-4 py-1" : "gap-4 py-4"} | ||
| 63 | accessibilityLiveRegion="polite" | ||
| 64 | > | ||
| 65 | <View className="flex-row items-center gap-2"> | ||
| 66 | <SymbolView | ||
| 67 | name={ | ||
| 68 | state.running | ||
| 69 | ? "clock" | ||
| 70 | : error | ||
| 71 | ? "exclamationmark.circle" | ||
| 72 | : stale | ||
| 73 | ? "arrow.clockwise" | ||
| 74 | : "list.number" | ||
| 75 | } | ||
| 76 | size={20} | ||
| 77 | tintColorClassName={ | ||
| 78 | error | ||
| 79 | ? "accent-danger-foreground" | ||
| 80 | : stale | ||
| 81 | ? "accent-warning-foreground" | ||
| 82 | : "accent-icon-muted" | ||
| 83 | } | ||
| 84 | /> | ||
| 85 | <Text className={guide ? "text-base font-t3-semibold" : "text-xl font-t3-semibold"}> | ||
| 86 | {label} | ||
| 87 | </Text> | ||
| 88 | </View> | ||
| 89 | <Text className="text-sm leading-6 text-foreground-muted"> | ||
| 90 | {error ?? | ||
| 91 | (state.running | ||
| 92 | ? "An agent is reading the change. You can leave this screen and return when it’s ready." | ||
| 93 | : stale | ||
| 94 | ? "There are new changes since this guide was written. Rewrite it to see the latest code." | ||
| 95 | : "Walk through the important code in order, with notes on what changed and where to look closely.")} | ||
| 96 | </Text> | ||
| 97 | {state.running ? ( | ||
| 98 | <Text className="text-xs text-foreground-muted"> | ||
| 99 | {state.data?.run?._tag === "Running" | ||
| 100 | ? `Started at ${new Date(state.data.run.startedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` | ||
| 101 | : "Starting the guide…"} | ||
| 102 | </Text> | ||
| 103 | ) : state.canWrite ? ( | ||
| 104 | <MaterialButton | ||
| 105 | label={error ? "Try again" : guide ? "Rewrite guide" : "Write guide"} | ||
| 106 | tone={guide ? "secondary" : "contrast"} | ||
| 107 | onPress={() => void state.write()} | ||
| 108 | /> | ||
| 109 | ) : null} | ||
| 110 | </View> | ||
| 111 | ); | ||
| 112 | } | ||
| 113 | |||
| 114 | export function PullRequestGuideContent({ params }: { readonly params: PullRequestParams }) { | 49 | export function PullRequestGuideContent({ params }: { readonly params: PullRequestParams }) { |
| 115 | const navigation = useNavigation(); | 50 | const navigation = useNavigation(); |
| 116 | const state = useReviewGuide(params); | 51 | const state = useReviewGuide(params); |
| 117 | const { guide, stale } = reviewGuideState(state.data); | 52 | const { guide, stale } = reviewGuideState(state.data); |
| 118 | return ( | 53 | return ( |
| 119 | <View className="w-full max-w-[760px] self-center gap-5"> | 54 | <Animated.View layout={PR_ROW_LAYOUT} className="w-full max-w-[760px] self-center"> |
| 120 | {!state.enabled ? ( | 55 | {!state.enabled ? ( |
| 121 | <Text>Review guides are disabled for this server.</Text> | 56 | <Text>Review guides are disabled for this server.</Text> |
| 122 | ) : ( | 57 | ) : ( |
| 123 | <> | 58 | <> |
| 124 | {state.isPending && !state.data ? ( | 59 | <GuideNotice state={state} /> |
| 125 | <Text className="text-foreground-muted">Loading review guide…</Text> | ||
| 126 | ) : ( | ||
| 127 | <GuideNotice state={state} /> | ||
| 128 | )} | ||
| 129 | {guide ? ( | 60 | {guide ? ( |
| 130 | <GuideOutline | 61 | <Animated.View |
| 131 | guide={guide} | 62 | key={`outline:${guide.headSha}:${guide.generatedAt}`} |
| 132 | onOpen={(position) => | 63 | layout={PR_ROW_LAYOUT} |
| 133 | navigation.dispatch( | 64 | entering={PR_ROW_ENTER} |
| 134 | StackActions.push("PullRequestGuideStop", { | 65 | exiting={PR_ROW_EXIT} |
| 135 | ...params, | 66 | > |
| 136 | position, | 67 | <GuideOutline |
| 137 | guideHeadSha: guide.headSha, | 68 | guide={guide} |
| 138 | guideGeneratedAt: guide.generatedAt, | 69 | onOpen={(position) => |
| 139 | }), | 70 | navigation.dispatch( |
| 140 | ) | 71 | StackActions.push("PullRequestGuideStop", { |
| 141 | } | 72 | ...params, |
| 142 | onRewrite={ | 73 | position, |
| 143 | state.canWrite && !state.running && !stale && !state.startError && !state.error | 74 | guideHeadSha: guide.headSha, |
| 144 | ? () => void state.write() | 75 | guideGeneratedAt: guide.generatedAt, |
| 145 | : undefined | 76 | }), |
| 146 | } | 77 | ) |
| 147 | /> | 78 | } |
| 79 | onRewrite={ | ||
| 80 | state.canWrite && !state.running && !stale && !state.startError && !state.error | ||
| 81 | ? () => void state.write() | ||
| 82 | : undefined | ||
| 83 | } | ||
| 84 | /> | ||
| 85 | </Animated.View> | ||
| 148 | ) : null} | 86 | ) : null} |
| 149 | </> | 87 | </> |
| 150 | )} | 88 | )} |
| 151 | </View> | 89 | </Animated.View> |
| 152 | ); | 90 | ); |
| 153 | } | 91 | } |
| 154 | 92 | ||
| ... | @@ -240,6 +178,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideSto | ... | @@ -240,6 +178,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideSto |
| 240 | </NativeHeaderToolbar.Menu> | 178 | </NativeHeaderToolbar.Menu> |
| 241 | </NativeHeaderToolbar> | 179 | </NativeHeaderToolbar> |
| 242 | <PullRequestMarkdownRepository | 180 | <PullRequestMarkdownRepository |
| 181 | params={route.params} | ||
| 243 | value={`https://${route.params.host ?? "github.com"}/${route.params.repository}`} | 182 | value={`https://${route.params.host ?? "github.com"}/${route.params.repository}`} |
| 244 | > | 183 | > |
| 245 | <ScreenScrollView | 184 | <ScreenScrollView |
apps/mobile/src/features/pull-requests/guide/reviewGuideState.test.ts+56-1| ... | @@ -4,7 +4,7 @@ import { | ... | @@ -4,7 +4,7 @@ import { |
| 4 | ProviderInstanceId, | 4 | ProviderInstanceId, |
| 5 | type PullRequestReviewGuideResult, | 5 | type PullRequestReviewGuideResult, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { reviewGuideState } from "./reviewGuideState"; | 7 | import { guideNoticeState, reviewGuideState } from "./reviewGuideState"; |
| 8 | 8 | ||
| 9 | const guide: NonNullable<PullRequestReviewGuideResult["guide"]> = { | 9 | const guide: NonNullable<PullRequestReviewGuideResult["guide"]> = { |
| 10 | headSha: "old-head", | 10 | headSha: "old-head", |
| ... | @@ -46,3 +46,58 @@ describe("mobile review guide state", () => { | ... | @@ -46,3 +46,58 @@ describe("mobile review guide state", () => { |
| 46 | }); | 46 | }); |
| 47 | }); | 47 | }); |
| 48 | }); | 48 | }); |
| 49 | |||
| 50 | const view = { | ||
| 51 | data: null, | ||
| 52 | isPending: false, | ||
| 53 | running: false, | ||
| 54 | startError: null, | ||
| 55 | error: null, | ||
| 56 | }; | ||
| 57 | |||
| 58 | describe("guide notice transitions", () => { | ||
| 59 | it("does not flash an empty guide before the first response", () => { | ||
| 60 | expect(guideNoticeState({ ...view, isPending: true }).kind).toBe("loading"); | ||
| 61 | expect( | ||
| 62 | guideNoticeState({ ...view, data: { guide: null, run: null, currentHeadSha: "head" } }).kind, | ||
| 63 | ).toBe("empty"); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("keeps cached guides visible while refreshing", () => { | ||
| 67 | expect( | ||
| 68 | guideNoticeState({ | ||
| 69 | ...view, | ||
| 70 | isPending: true, | ||
| 71 | data: { guide, run: null, currentHeadSha: guide.headSha }, | ||
| 72 | }).kind, | ||
| 73 | ).toBe("ready"); | ||
| 74 | }); | ||
| 75 | |||
| 76 | it("hides the previous failure as soon as a retry starts", () => { | ||
| 77 | const data = { | ||
| 78 | guide: null, | ||
| 79 | currentHeadSha: "head", | ||
| 80 | run: { | ||
| 81 | _tag: "Failed" as const, | ||
| 82 | headSha: "head", | ||
| 83 | failedAt: guide.generatedAt, | ||
| 84 | detail: "Writer disconnected", | ||
| 85 | }, | ||
| 86 | }; | ||
| 87 | expect(guideNoticeState({ ...view, data }).kind).toBe("failed"); | ||
| 88 | expect(guideNoticeState({ ...view, data, running: true }).kind).toBe("writing"); | ||
| 89 | }); | ||
| 90 | |||
| 91 | it("keeps a stale guide readable through rewriting and completion", () => { | ||
| 92 | const data = { guide, currentHeadSha: "head", run: null }; | ||
| 93 | expect(guideNoticeState({ ...view, data }).kind).toBe("stale"); | ||
| 94 | expect(guideNoticeState({ ...view, data, running: true }).kind).toBe("writing"); | ||
| 95 | expect( | ||
| 96 | guideNoticeState({ ...view, data: { ...data, guide: { ...guide, headSha: "head" } } }).kind, | ||
| 97 | ).toBe("ready"); | ||
| 98 | }); | ||
| 99 | }); | ||
| 100 | |||
| 101 | it("distinguishes a failed read from a failed writer", () => { | ||
| 102 | expect(guideNoticeState({ ...view, error: "Connection lost" }).kind).toBe("unavailable"); | ||
| 103 | }); |
apps/mobile/src/features/pull-requests/guide/reviewGuideState.ts+61| ... | @@ -13,3 +13,64 @@ export function reviewGuideState(data: PullRequestReviewGuideResult | null) { | ... | @@ -13,3 +13,64 @@ export function reviewGuideState(data: PullRequestReviewGuideResult | null) { |
| 13 | : null, | 13 | : null, |
| 14 | }; | 14 | }; |
| 15 | } | 15 | } |
| 16 | |||
| 17 | /** A pending refresh must not hide cached content, or a new run reveal an old failure. */ | ||
| 18 | export function guideNoticeState(state: { | ||
| 19 | readonly data: PullRequestReviewGuideResult | null; | ||
| 20 | readonly isPending: boolean; | ||
| 21 | readonly running: boolean; | ||
| 22 | readonly startError: string | null; | ||
| 23 | readonly error: string | null; | ||
| 24 | }) { | ||
| 25 | const { guide, stale, failure } = reviewGuideState(state.data); | ||
| 26 | if (state.running) { | ||
| 27 | return { | ||
| 28 | kind: "writing" as const, | ||
| 29 | title: "Rewriting guide", | ||
| 30 | description: guide | ||
| 31 | ? "You can keep reading this guide while the agent writes the update." | ||
| 32 | : "An agent is reading the change and writing the tour. You can leave and come back when it’s ready.", | ||
| 33 | action: guide ? "Rewriting…" : "Writing guide…", | ||
| 34 | }; | ||
| 35 | } | ||
| 36 | const error = state.startError ?? failure; | ||
| 37 | if (error) { | ||
| 38 | return { | ||
| 39 | kind: "failed" as const, | ||
| 40 | title: "Could not write guide", | ||
| 41 | description: error, | ||
| 42 | action: "Try again", | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | if (state.error) { | ||
| 46 | return { | ||
| 47 | kind: "unavailable" as const, | ||
| 48 | title: "Could not refresh guide", | ||
| 49 | description: state.error, | ||
| 50 | action: "Try again", | ||
| 51 | }; | ||
| 52 | } | ||
| 53 | if (!state.data) { | ||
| 54 | return { | ||
| 55 | kind: "loading" as const, | ||
| 56 | title: "Review guide", | ||
| 57 | description: "Checking for a saved guide…", | ||
| 58 | action: "Loading guide…", | ||
| 59 | }; | ||
| 60 | } | ||
| 61 | if (stale) { | ||
| 62 | return { | ||
| 63 | kind: "stale" as const, | ||
| 64 | title: "New changes since this guide", | ||
| 65 | description: "You can read the previous guide, or rewrite it for the latest changes.", | ||
| 66 | action: "Rewrite guide", | ||
| 67 | }; | ||
| 68 | } | ||
| 69 | return { | ||
| 70 | kind: guide ? ("ready" as const) : ("empty" as const), | ||
| 71 | title: "Review guide", | ||
| 72 | description: | ||
| 73 | "Get a tour of the change, with code excerpts and notes on what needs a closer look.", | ||
| 74 | action: "Write guide", | ||
| 75 | }; | ||
| 76 | } |
apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts+4-3| ... | @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; | ... | @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useFocusEffect } from "@react-navigation/native"; | 2 | import { useFocusEffect } from "@react-navigation/native"; |
| 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 4 | import { useCallback, useRef, useState } from "react"; | 4 | import { useCallback, useRef, useState } from "react"; |
| 5 | import { usePullRequestConnected } from "../usePullRequestConnected"; | ||
| 5 | import { composerPullRequests } from "../../../state/pull-requests"; | 6 | import { composerPullRequests } from "../../../state/pull-requests"; |
| 6 | import { useEnvironmentQuery } from "../../../state/query"; | 7 | import { useEnvironmentQuery } from "../../../state/query"; |
| 7 | import { serverEnvironment } from "../../../state/server"; | 8 | import { serverEnvironment } from "../../../state/server"; |
| ... | @@ -19,9 +20,11 @@ export function useReviewGuide(params: PullRequestParams) { | ... | @@ -19,9 +20,11 @@ export function useReviewGuide(params: PullRequestParams) { |
| 19 | const target = pullRequestTarget(params); | 20 | const target = pullRequestTarget(params); |
| 20 | const enabled = useReviewGuidesEnabled(params); | 21 | const enabled = useReviewGuidesEnabled(params); |
| 21 | const query = useEnvironmentQuery(enabled ? composerPullRequests.reviewGuide(target) : null); | 22 | const query = useEnvironmentQuery(enabled ? composerPullRequests.reviewGuide(target) : null); |
| 22 | const canWrite = useAtomValue( | 23 | const connected = usePullRequestConnected(target.environmentId); |
| 24 | const permitted = useAtomValue( | ||
| 23 | composerPullRequests.generateReviewGuide.permissionAtom(target.environmentId), | 25 | composerPullRequests.generateReviewGuide.permissionAtom(target.environmentId), |
| 24 | ); | 26 | ); |
| 27 | const canWrite = connected && permitted; | ||
| 25 | const generate = useAtomCommand(composerPullRequests.generateReviewGuide, { | 28 | const generate = useAtomCommand(composerPullRequests.generateReviewGuide, { |
| 26 | reportFailure: false, | 29 | reportFailure: false, |
| 27 | }); | 30 | }); |
| ... | @@ -49,8 +52,6 @@ export function useReviewGuide(params: PullRequestParams) { | ... | @@ -49,8 +52,6 @@ export function useReviewGuide(params: PullRequestParams) { |
| 49 | if (result._tag === "Failure") { | 52 | if (result._tag === "Failure") { |
| 50 | const cause = squashAtomCommandFailure(result); | 53 | const cause = squashAtomCommandFailure(result); |
| 51 | setError(cause instanceof Error ? cause.message : "Could not start the review guide."); | 54 | setError(cause instanceof Error ? cause.message : "Could not start the review guide."); |
| 52 | } else { | ||
| 53 | refresh(); | ||
| 54 | } | 55 | } |
| 55 | } finally { | 56 | } finally { |
| 56 | pending.current = false; | 57 | pending.current = false; |
apps/mobile/src/features/pull-requests/pullRequestDescription.test.ts+2-2| ... | @@ -15,8 +15,8 @@ describe("PR description overview", () => { | ... | @@ -15,8 +15,8 @@ describe("PR description overview", () => { |
| 15 | ], | 15 | ], |
| 16 | }); | 16 | }); |
| 17 | expect(result.attachments).toEqual([ | 17 | expect(result.attachments).toEqual([ |
| 18 | { uri: "https://example.com/before.png", caption: "Before" }, | 18 | { uri: "https://example.com/before.png", caption: "Before", kind: "image" }, |
| 19 | { uri: "https://example.com/after.png", caption: "After" }, | 19 | { uri: "https://example.com/after.png", caption: "After", kind: "image" }, |
| 20 | ]); | 20 | ]); |
| 21 | expect(result.preview).toContain("Fixes reconnects."); | 21 | expect(result.preview).toContain("Fixes reconnects."); |
| 22 | expect(result.preview).not.toContain("Duplicate"); | 22 | expect(result.preview).not.toContain("Duplicate"); |
apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.test.ts created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | ||
| 3 | import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown"; | ||
| 4 | import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; | ||
| 5 | |||
| 6 | describe("PR attachments", () => { | ||
| 7 | it("combines embedded images and detected media without duplicate URLs", () => { | ||
| 8 | const uri = "https://drop.example.com/shot.png"; | ||
| 9 | const document = { | ||
| 10 | type: "document", | ||
| 11 | children: [ | ||
| 12 | { type: "image", href: uri, alt: "Screenshot" }, | ||
| 13 | { type: "image", href: uri, alt: "Duplicate" }, | ||
| 14 | ], | ||
| 15 | } as MarkdownNode; | ||
| 16 | const media = splitPullRequestBody( | ||
| 17 | `${uri}\n\nhttps://drop.example.com/demo.mp4\n\nhttps://github.com/user-attachments/assets/123-abc`, | ||
| 18 | ); | ||
| 19 | expect(pullRequestDescriptionContent(document, media).attachments).toEqual([ | ||
| 20 | { uri, caption: "Screenshot", kind: "image" }, | ||
| 21 | { uri: "https://drop.example.com/demo.mp4", caption: "Video", kind: "video" }, | ||
| 22 | { | ||
| 23 | uri: "https://github.com/user-attachments/assets/123-abc", | ||
| 24 | caption: "Video", | ||
| 25 | kind: "video", | ||
| 26 | }, | ||
| 27 | ]); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("does not collect ordinary links or media examples in code", () => { | ||
| 31 | const media = splitPullRequestBody( | ||
| 32 | "https://example.com/docs\n\n```\nhttps://drop.example.com/demo.mp4\n```", | ||
| 33 | ); | ||
| 34 | expect( | ||
| 35 | pullRequestDescriptionContent({ type: "document" } as MarkdownNode, media).attachments, | ||
| 36 | ).toEqual([]); | ||
| 37 | }); | ||
| 38 | }); | ||
apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.ts+24-3| ... | @@ -1,13 +1,24 @@ | ... | @@ -1,13 +1,24 @@ |
| 1 | import type { PullRequestBodySegment } from "@t3tools/client-runtime/pull-request-markdown"; | ||
| 1 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | 2 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 2 | 3 | ||
| 3 | /** Read media from the parsed document so code examples never become attachments. */ | 4 | /** Read media from the parsed document so code examples never become attachments. */ |
| 4 | export function pullRequestDescriptionContent(document: MarkdownNode) { | 5 | export function pullRequestDescriptionContent( |
| 5 | const attachments = new Map<string, { readonly uri: string; readonly caption: string }>(); | 6 | document: MarkdownNode, |
| 7 | media: readonly PullRequestBodySegment[] = [], | ||
| 8 | ) { | ||
| 9 | const attachments = new Map< | ||
| 10 | string, | ||
| 11 | { readonly uri: string; readonly caption: string; readonly kind: "image" | "video" | "unknown" } | ||
| 12 | >(); | ||
| 6 | const text: string[] = []; | 13 | const text: string[] = []; |
| 7 | const visit = (node: MarkdownNode) => { | 14 | const visit = (node: MarkdownNode) => { |
| 8 | if (node.type === "image") { | 15 | if (node.type === "image") { |
| 9 | if (node.href && /^https?:\/\//i.test(node.href) && !attachments.has(node.href)) { | 16 | if (node.href && /^https?:\/\//i.test(node.href) && !attachments.has(node.href)) { |
| 10 | attachments.set(node.href, { uri: node.href, caption: node.alt || node.title || "Image" }); | 17 | attachments.set(node.href, { |
| 18 | uri: node.href, | ||
| 19 | caption: node.alt || node.title || "Image", | ||
| 20 | kind: "image", | ||
| 21 | }); | ||
| 11 | } | 22 | } |
| 12 | return; | 23 | return; |
| 13 | } | 24 | } |
| ... | @@ -18,5 +29,15 @@ export function pullRequestDescriptionContent(document: MarkdownNode) { | ... | @@ -18,5 +29,15 @@ export function pullRequestDescriptionContent(document: MarkdownNode) { |
| 18 | if (node.type === "soft_break" || node.type === "line_break") text.push(" "); | 29 | if (node.type === "soft_break" || node.type === "line_break") text.push(" "); |
| 19 | }; | 30 | }; |
| 20 | visit(document); | 31 | visit(document); |
| 32 | for (const segment of media) { | ||
| 33 | if (segment.kind === "attachment" && !attachments.has(segment.url)) { | ||
| 34 | const name = new URL(segment.url).pathname.split("/").pop(); | ||
| 35 | attachments.set(segment.url, { | ||
| 36 | uri: segment.url, | ||
| 37 | caption: segment.media === "video" ? "Video" : name || "Image", | ||
| 38 | kind: segment.media, | ||
| 39 | }); | ||
| 40 | } | ||
| 41 | } | ||
| 21 | return { preview: text.join("").trim(), attachments: [...attachments.values()] }; | 42 | return { preview: text.join("").trim(), attachments: [...attachments.values()] }; |
| 22 | } | 43 | } |
apps/mobile/src/features/pull-requests/pullRequestMediaSource.test.ts created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { filePreviewGallery } from "../../lib/filePreviewGallery"; | ||
| 4 | import { mediaVideoPreviewUri, mediaVideoThumbnailKey } from "../../lib/videoPreviewSource"; | ||
| 5 | import { pullRequestImageSource, pullRequestMediaSource } from "./pullRequestMediaSource"; | ||
| 6 | |||
| 7 | const location = { environmentId: EnvironmentId.make("test"), cwd: "/repo" }; | ||
| 8 | const url = "https://github.com/user-attachments/assets/abc-123"; | ||
| 9 | |||
| 10 | describe("PR media previews", () => { | ||
| 11 | it("keeps a video resource pending until resolved and accepts refreshed capabilities", () => { | ||
| 12 | const source = { | ||
| 13 | type: "media" as const, | ||
| 14 | name: "Video", | ||
| 15 | mimeType: "video/mp4", | ||
| 16 | ...pullRequestMediaSource(url, location), | ||
| 17 | }; | ||
| 18 | expect(mediaVideoPreviewUri(source, null)).toBeNull(); | ||
| 19 | expect(mediaVideoPreviewUri(source, "https://signed/first")).toBe("https://signed/first"); | ||
| 20 | expect(mediaVideoPreviewUri(source, "https://signed/renewed")).toBe("https://signed/renewed"); | ||
| 21 | expect(mediaVideoThumbnailKey(source)).toContain("github-media"); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("keeps every gallery image resolvable and the tapped image appears only once", () => { | ||
| 25 | const first = pullRequestImageSource(url, location); | ||
| 26 | const second = pullRequestImageSource(`${url}-second`, location); | ||
| 27 | const selected = { ...second, sourceIdentifier: "tapped" }; | ||
| 28 | const gallery = filePreviewGallery(selected, [first, second]); | ||
| 29 | expect(gallery).toEqual([first, selected]); | ||
| 30 | expect(gallery.every((item) => "resource" in item)).toBe(true); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("leaves custom-domain media as direct URLs", () => { | ||
| 34 | const external = "https://drop.example/file.mp4"; | ||
| 35 | expect(pullRequestMediaSource(external, location)).toEqual({ uri: external }); | ||
| 36 | }); | ||
| 37 | }); | ||
apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; | ||
| 3 | import type { FilePreviewItem } from "../../components/FilePreviewModal.types"; | ||
| 4 | |||
| 5 | export interface PullRequestMediaLocation { | ||
| 6 | readonly environmentId: EnvironmentId; | ||
| 7 | readonly cwd: string; | ||
| 8 | } | ||
| 9 | |||
| 10 | /** Keep the original resource identity so previewers can resolve and renew its capability. */ | ||
| 11 | export function pullRequestMediaSource(url: string, location: PullRequestMediaLocation | null) { | ||
| 12 | const fetchUrl = githubMediaFetchUrl(url); | ||
| 13 | return location && fetchUrl | ||
| 14 | ? { | ||
| 15 | environmentId: location.environmentId, | ||
| 16 | resource: { _tag: "github-media" as const, cwd: location.cwd, url: fetchUrl }, | ||
| 17 | } | ||
| 18 | : { uri: url }; | ||
| 19 | } | ||
| 20 | |||
| 21 | export function pullRequestImageSource( | ||
| 22 | url: string, | ||
| 23 | location: PullRequestMediaLocation | null, | ||
| 24 | ): FilePreviewItem { | ||
| 25 | return { kind: "image", ...pullRequestMediaSource(url, location) }; | ||
| 26 | } | ||
apps/mobile/src/features/pull-requests/usePullRequestAgentActions.test.ts created+55| ... | @@ -0,0 +1,55 @@ | ||
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | import type { PullRequestDetail } from "@t3tools/contracts"; | ||
| 3 | import { usePullRequestAgentActions } from "./usePullRequestAgentActions"; | ||
| 4 | |||
| 5 | const state = vi.hoisted(() => ({ | ||
| 6 | connected: false, | ||
| 7 | canAsk: true, | ||
| 8 | ask: vi.fn(), | ||
| 9 | })); | ||
| 10 | vi.mock("./usePullRequestConnected", () => ({ | ||
| 11 | usePullRequestConnected: () => state.connected, | ||
| 12 | })); | ||
| 13 | vi.mock("./usePullRequestThreads", () => ({ | ||
| 14 | usePullRequestThreads: () => ({ | ||
| 15 | threads: [], | ||
| 16 | canAsk: state.canAsk, | ||
| 17 | canWatch: true, | ||
| 18 | busy: false, | ||
| 19 | isPending: true, | ||
| 20 | ask: state.ask, | ||
| 21 | }), | ||
| 22 | })); | ||
| 23 | const params = { | ||
| 24 | environmentId: "environment", | ||
| 25 | projectId: "project", | ||
| 26 | repository: "owner/repo", | ||
| 27 | number: 1, | ||
| 28 | }; | ||
| 29 | const pr = { state: "open", mergeability: "conflicting" } as PullRequestDetail; | ||
| 30 | |||
| 31 | beforeEach(() => { | ||
| 32 | state.connected = false; | ||
| 33 | state.canAsk = true; | ||
| 34 | state.ask.mockClear(); | ||
| 35 | }); | ||
| 36 | |||
| 37 | describe("PR agent drafts", () => { | ||
| 38 | it("opens offline drafts without waiting for linked-thread discovery", () => { | ||
| 39 | const actions = usePullRequestAgentActions(params, pr); | ||
| 40 | expect(actions.disabled).toBe(false); | ||
| 41 | actions.start("ask"); | ||
| 42 | actions.start("explain"); | ||
| 43 | expect(state.ask.mock.calls).toEqual([ | ||
| 44 | [undefined, "ask"], | ||
| 45 | [undefined, "explain"], | ||
| 46 | ]); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("does not open drafts when the connection denies task access", () => { | ||
| 50 | state.connected = true; | ||
| 51 | state.canAsk = false; | ||
| 52 | usePullRequestAgentActions(params, pr).start("explain"); | ||
| 53 | expect(state.ask).not.toHaveBeenCalled(); | ||
| 54 | }); | ||
| 55 | }); | ||
apps/mobile/src/features/pull-requests/usePullRequestAgentActions.ts+3-1| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | type PullRequestAgentTask, | 5 | type PullRequestAgentTask, |
| 6 | } from "./pullRequestAgentTasks"; | 6 | } from "./pullRequestAgentTasks"; |
| 7 | import type { PullRequestParams } from "./usePullRequestDiff"; | 7 | import type { PullRequestParams } from "./usePullRequestDiff"; |
| 8 | import { usePullRequestConnected } from "./usePullRequestConnected"; | ||
| 8 | import { usePullRequestThreads } from "./usePullRequestThreads"; | 9 | import { usePullRequestThreads } from "./usePullRequestThreads"; |
| 9 | 10 | ||
| 10 | /** Shared destinations for toolbar helpers and actions beside PR readiness rows. */ | 11 | /** Shared destinations for toolbar helpers and actions beside PR readiness rows. */ |
| ... | @@ -14,8 +15,9 @@ export function usePullRequestAgentActions( | ... | @@ -14,8 +15,9 @@ export function usePullRequestAgentActions( |
| 14 | filePath?: string, | 15 | filePath?: string, |
| 15 | ) { | 16 | ) { |
| 16 | const links = usePullRequestThreads(params, pr, filePath); | 17 | const links = usePullRequestThreads(params, pr, filePath); |
| 18 | const connected = usePullRequestConnected(params.environmentId); | ||
| 17 | const actions = pr ? pullRequestAgentActions(pr, links.canWatch) : []; | 19 | const actions = pr ? pullRequestAgentActions(pr, links.canWatch) : []; |
| 18 | const disabled = links.busy || !pr || (links.isPending && !links.threads.length); | 20 | const disabled = links.busy || !pr || (connected && links.isPending && !links.threads.length); |
| 19 | const start = (task: PullRequestAgentTask, threadId?: string) => { | 21 | const start = (task: PullRequestAgentTask, threadId?: string) => { |
| 20 | if (disabled) return; | 22 | if (disabled) return; |
| 21 | const thread = pullRequestAgentThread(links.threads, threadId); | 23 | const thread = pullRequestAgentThread(links.threads, threadId); |
apps/mobile/src/features/pull-requests/usePullRequestConnected.ts created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | import { useWorkspaceEnvironments } from "../../state/workspace"; | ||
| 2 | |||
| 3 | export function usePullRequestConnected(environmentId: string) { | ||
| 4 | return useWorkspaceEnvironments().some( | ||
| 5 | (environment) => | ||
| 6 | environment.environmentId === environmentId && environment.connectionState === "connected", | ||
| 7 | ); | ||
| 8 | } | ||
apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts+4-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { usePullRequestConnected } from "./usePullRequestConnected"; | ||
| 1 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 2 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 3 | import type { PullRequestDetail } from "@t3tools/contracts"; | 4 | import type { PullRequestDetail } from "@t3tools/contracts"; |
| ... | @@ -12,9 +13,11 @@ export function usePullRequestFilesViewed(params: PullRequestParams, pr: PullReq | ... | @@ -12,9 +13,11 @@ export function usePullRequestFilesViewed(params: PullRequestParams, pr: PullReq |
| 12 | const target = pullRequestTarget(params); | 13 | const target = pullRequestTarget(params); |
| 13 | const scopeKey = pullRequestReviewKey(params); | 14 | const scopeKey = pullRequestReviewKey(params); |
| 14 | const supported = Boolean(pr?.capabilities.viewedFiles); | 15 | const supported = Boolean(pr?.capabilities.viewedFiles); |
| 15 | const allowed = useAtomValue( | 16 | const permission = useAtomValue( |
| 16 | composerPullRequests.setFilesViewed.permissionAtom(target.environmentId), | 17 | composerPullRequests.setFilesViewed.permissionAtom(target.environmentId), |
| 17 | ); | 18 | ); |
| 19 | const connected = usePullRequestConnected(params.environmentId); | ||
| 20 | const allowed = connected && permission; | ||
| 18 | const query = useEnvironmentQuery(supported ? composerPullRequests.filesViewed(target) : null); | 21 | const query = useEnvironmentQuery(supported ? composerPullRequests.filesViewed(target) : null); |
| 19 | const refresh = useAtomRefresh(composerPullRequests.filesViewed(target)); | 22 | const refresh = useAtomRefresh(composerPullRequests.filesViewed(target)); |
| 20 | const write = useAtomCommand(composerPullRequests.setFilesViewed, { reportFailure: false }); | 23 | const write = useAtomCommand(composerPullRequests.setFilesViewed, { reportFailure: false }); |
apps/mobile/src/features/pull-requests/usePullRequestPermissions.ts+17-5| ... | @@ -1,10 +1,12 @@ | ... | @@ -1,10 +1,12 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { PullRequestDetail } from "@t3tools/contracts"; | 2 | import type { PullRequestDetail } from "@t3tools/contracts"; |
| 3 | import { usePullRequestConnected } from "./usePullRequestConnected"; | ||
| 3 | import { composerPullRequests } from "../../state/pull-requests"; | 4 | import { composerPullRequests } from "../../state/pull-requests"; |
| 4 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; | 5 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; |
| 5 | 6 | ||
| 6 | export function usePullRequestPermissions(params: PullRequestParams, pr: PullRequestDetail | null) { | 7 | export function usePullRequestPermissions(params: PullRequestParams, pr: PullRequestDetail | null) { |
| 7 | const { environmentId } = pullRequestTarget(params); | 8 | const { environmentId } = pullRequestTarget(params); |
| 9 | const connected = usePullRequestConnected(environmentId); | ||
| 8 | const canWrite = useAtomValue(composerPullRequests.runAction.permissionAtom(environmentId)); | 10 | const canWrite = useAtomValue(composerPullRequests.runAction.permissionAtom(environmentId)); |
| 9 | const canComment = useAtomValue(composerPullRequests.comment.permissionAtom(environmentId)); | 11 | const canComment = useAtomValue(composerPullRequests.comment.permissionAtom(environmentId)); |
| 10 | const canReview = useAtomValue(composerPullRequests.submitReview.permissionAtom(environmentId)); | 12 | const canReview = useAtomValue(composerPullRequests.submitReview.permissionAtom(environmentId)); |
| ... | @@ -13,7 +15,9 @@ export function usePullRequestPermissions(params: PullRequestParams, pr: PullReq | ... | @@ -13,7 +15,9 @@ export function usePullRequestPermissions(params: PullRequestParams, pr: PullReq |
| 13 | composerPullRequests.setThreadResolution.permissionAtom(environmentId), | 15 | composerPullRequests.setThreadResolution.permissionAtom(environmentId), |
| 14 | ); | 16 | ); |
| 15 | return { | 17 | return { |
| 18 | connected, | ||
| 16 | canInlineComment: | 19 | canInlineComment: |
| 20 | connected && | ||
| 17 | canReview && | 21 | canReview && |
| 18 | pr?.state === "open" && | 22 | pr?.state === "open" && |
| 19 | pr.capabilities.review.inlineComment && | 23 | pr.capabilities.review.inlineComment && |
| ... | @@ -21,13 +25,21 @@ export function usePullRequestPermissions(params: PullRequestParams, pr: PullReq | ... | @@ -21,13 +25,21 @@ export function usePullRequestPermissions(params: PullRequestParams, pr: PullReq |
| 21 | pr.capabilities.review.verdicts.some((verdict) => | 25 | pr.capabilities.review.verdicts.some((verdict) => |
| 22 | pr.viewerPermissions.verdicts.includes(verdict), | 26 | pr.viewerPermissions.verdicts.includes(verdict), |
| 23 | ), | 27 | ), |
| 24 | canReply: canReply && pr?.capabilities.review.reply === true && pr.viewerPermissions.comment, | 28 | canReply: |
| 29 | connected && | ||
| 30 | canReply && | ||
| 31 | pr?.capabilities.review.reply === true && | ||
| 32 | pr.viewerPermissions.comment, | ||
| 25 | canResolve: | 33 | canResolve: |
| 26 | canResolve && pr?.capabilities.review.resolve === true && pr.viewerPermissions.resolve, | 34 | connected && |
| 27 | canWrite, | 35 | canResolve && |
| 28 | canComment: canComment && pr?.capabilities.comment === true && pr.viewerPermissions.comment, | 36 | pr?.capabilities.review.resolve === true && |
| 37 | pr.viewerPermissions.resolve, | ||
| 38 | canWrite: connected && canWrite, | ||
| 39 | canComment: | ||
| 40 | connected && canComment && pr?.capabilities.comment === true && pr.viewerPermissions.comment, | ||
| 29 | verdicts: | 41 | verdicts: |
| 30 | canReview && pr?.state === "open" | 42 | connected && canReview && pr?.state === "open" |
| 31 | ? pr.capabilities.review.verdicts.filter((verdict) => | 43 | ? pr.capabilities.review.verdicts.filter((verdict) => |
| 32 | pr.viewerPermissions.verdicts.includes(verdict), | 44 | pr.viewerPermissions.verdicts.includes(verdict), |
| 33 | ) | 45 | ) |
apps/mobile/src/features/pull-requests/usePullRequestThreads.ts+6-2| ... | @@ -27,6 +27,7 @@ import { | ... | @@ -27,6 +27,7 @@ import { |
| 27 | updateComposerDraftSettings, | 27 | updateComposerDraftSettings, |
| 28 | waitForComposerDraftsLoaded, | 28 | waitForComposerDraftsLoaded, |
| 29 | } from "../../state/use-composer-drafts"; | 29 | } from "../../state/use-composer-drafts"; |
| 30 | import { usePullRequestConnected } from "./usePullRequestConnected"; | ||
| 30 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; | 31 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; |
| 31 | import { | 32 | import { |
| 32 | appendPullRequestAgentPrompt, | 33 | appendPullRequestAgentPrompt, |
| ... | @@ -63,7 +64,10 @@ export function usePullRequestThreads( | ... | @@ -63,7 +64,10 @@ export function usePullRequestThreads( |
| 63 | setLastRelations({ key, data: relations.data }); | 64 | setLastRelations({ key, data: relations.data }); |
| 64 | const threads = | 65 | const threads = |
| 65 | (relations.data ?? (lastRelations.key === key ? lastRelations.data : null))?.threads ?? []; | 66 | (relations.data ?? (lastRelations.key === key ? lastRelations.data : null))?.threads ?? []; |
| 66 | const canAsk = useEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope); | 67 | const connected = usePullRequestConnected(params.environmentId); |
| 68 | const canOperate = useEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope); | ||
| 69 | // Composing offline only writes a local draft. The outbox checks grants on delivery. | ||
| 70 | const canAsk = !connected || canOperate; | ||
| 67 | const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope); | 71 | const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope); |
| 68 | const navigation = useNavigation(); | 72 | const navigation = useNavigation(); |
| 69 | const pending = useRef(false); | 73 | const pending = useRef(false); |
| ... | @@ -76,7 +80,7 @@ export function usePullRequestThreads( | ... | @@ -76,7 +80,7 @@ export function usePullRequestThreads( |
| 76 | pending.current = true; | 80 | pending.current = true; |
| 77 | setBusy(true); | 81 | setBusy(true); |
| 78 | try { | 82 | try { |
| 79 | if (!thread && !canCheckout) | 83 | if (connected && !thread && !canCheckout) |
| 80 | throw new Error("Starting work from this PR requires source control write access."); | 84 | throw new Error("Starting work from this PR requires source control write access."); |
| 81 | await waitForComposerDraftsLoaded(); | 85 | await waitForComposerDraftsLoaded(); |
| 82 | const draftKey = thread | 86 | const draftKey = thread |
apps/mobile/src/lib/videoPreviewSource.ts+17-9| ... | @@ -17,7 +17,7 @@ export type MediaVideoPreviewSource = { | ... | @@ -17,7 +17,7 @@ export type MediaVideoPreviewSource = { |
| 17 | readonly environmentId: EnvironmentId; | 17 | readonly environmentId: EnvironmentId; |
| 18 | readonly resource: Extract< | 18 | readonly resource: Extract< |
| 19 | AssetResource, | 19 | AssetResource, |
| 20 | { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" } | 20 | { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" | "github-media" } |
| 21 | >; | 21 | >; |
| 22 | } | 22 | } |
| 23 | ); | 23 | ); |
| ... | @@ -47,14 +47,22 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string | ... | @@ -47,14 +47,22 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string |
| 47 | source.resource.path, | 47 | source.resource.path, |
| 48 | source.srcFragment ?? "", | 48 | source.srcFragment ?? "", |
| 49 | ] | 49 | ] |
| 50 | : [ | 50 | : source.resource._tag === "github-media" |
| 51 | "media-video", | 51 | ? [ |
| 52 | "draft-workspace-file", | 52 | "media-video", |
| 53 | source.environmentId, | 53 | "github-media", |
| 54 | source.resource.cwd, | 54 | source.environmentId, |
| 55 | source.resource.path, | 55 | source.resource.cwd, |
| 56 | source.srcFragment ?? "", | 56 | source.resource.url, |
| 57 | ], | 57 | ] |
| 58 | : [ | ||
| 59 | "media-video", | ||
| 60 | "draft-workspace-file", | ||
| 61 | source.environmentId, | ||
| 62 | source.resource.cwd, | ||
| 63 | source.resource.path, | ||
| 64 | source.srcFragment ?? "", | ||
| 65 | ], | ||
| 58 | ); | 66 | ); |
| 59 | } | 67 | } |
| 60 | 68 |
apps/mobile/src/persistence/layer.ts+2| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import * as Layer from "effect/Layer"; | 1 | import * as Layer from "effect/Layer"; |
| 2 | 2 | ||
| 3 | import * as PullRequestCacheStore from "../connection/pull-request-cache-store"; | ||
| 3 | import * as EnvironmentCacheStore from "../connection/environment-cache-store"; | 4 | import * as EnvironmentCacheStore from "../connection/environment-cache-store"; |
| 4 | import * as MobileDatabase from "./mobile-database"; | 5 | import * as MobileDatabase from "./mobile-database"; |
| 5 | import * as MobilePreferences from "./mobile-preferences"; | 6 | import * as MobilePreferences from "./mobile-preferences"; |
| ... | @@ -11,6 +12,7 @@ const layerDependent = Layer.mergeAll( | ... | @@ -11,6 +12,7 @@ const layerDependent = Layer.mergeAll( |
| 11 | MobilePreferences.layer, | 12 | MobilePreferences.layer, |
| 12 | MobileStorage.layer, | 13 | MobileStorage.layer, |
| 13 | EnvironmentCacheStore.layer, | 14 | EnvironmentCacheStore.layer, |
| 15 | PullRequestCacheStore.layer, | ||
| 14 | ).pipe(Layer.provide(layerBase)); | 16 | ).pipe(Layer.provide(layerBase)); |
| 15 | 17 | ||
| 16 | export const layer = Layer.merge(layerBase, layerDependent); | 18 | export const layer = Layer.merge(layerBase, layerDependent); |
apps/mobile/src/persistence/mobile-database.test.ts+85| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Option from "effect/Option"; | ||
| 3 | import { vi } from "vite-plus/test"; | 4 | import { vi } from "vite-plus/test"; |
| 4 | 5 | ||
| 5 | const openDatabaseAsync = vi.hoisted(() => vi.fn()); | 6 | const openDatabaseAsync = vi.hoisted(() => vi.fn()); |
| ... | @@ -69,3 +70,87 @@ describe("mobile database legacy cache migration", () => { | ... | @@ -69,3 +70,87 @@ describe("mobile database legacy cache migration", () => { |
| 69 | ).toBeNull(); | 70 | ).toBeNull(); |
| 70 | }); | 71 | }); |
| 71 | }); | 72 | }); |
| 73 | |||
| 74 | // Exercise the production SQL against SQLite, including the eviction window functions. | ||
| 75 | describe("offline pull request cache", () => { | ||
| 76 | const environmentId = "cached-environment" as import("@t3tools/contracts").EnvironmentId; | ||
| 77 | const otherEnvironment = "other-environment" as import("@t3tools/contracts").EnvironmentId; | ||
| 78 | |||
| 79 | async function sqliteDatabase() { | ||
| 80 | const { DatabaseSync } = await import("node:sqlite"); | ||
| 81 | const sqlite = new DatabaseSync(":memory:"); | ||
| 82 | sqlite.exec("PRAGMA user_version = 1"); | ||
| 83 | const database = { | ||
| 84 | execAsync: async (sql: string) => { | ||
| 85 | sqlite.exec(sql); | ||
| 86 | }, | ||
| 87 | runAsync: async (sql: string, ...args: Array<string | number>) => | ||
| 88 | sqlite.prepare(sql).run(...args), | ||
| 89 | getFirstAsync: async (sql: string, ...args: Array<string | number>) => | ||
| 90 | sqlite.prepare(sql).get(...args), | ||
| 91 | getAllAsync: async (sql: string, ...args: Array<string | number>) => | ||
| 92 | sqlite.prepare(sql).all(...args), | ||
| 93 | withExclusiveTransactionAsync: async (run: (transaction: unknown) => Promise<void>) => { | ||
| 94 | await run(database); | ||
| 95 | }, | ||
| 96 | closeAsync: async () => { | ||
| 97 | sqlite.close(); | ||
| 98 | }, | ||
| 99 | }; | ||
| 100 | openDatabaseAsync.mockResolvedValueOnce(database); | ||
| 101 | return sqlite; | ||
| 102 | } | ||
| 103 | |||
| 104 | it.effect("restores saved records and clears them with the environment", () => | ||
| 105 | Effect.scoped( | ||
| 106 | Effect.gen(function* () { | ||
| 107 | yield* Effect.promise(sqliteDatabase); | ||
| 108 | const database = yield* MobileDatabase.make; | ||
| 109 | yield* database.saveCache(environmentId, "pull-request", "v1:list", 1, '{"entries":[]}'); | ||
| 110 | yield* database.saveCache(otherEnvironment, "pull-request", "v1:list", 1, "other"); | ||
| 111 | expect(yield* database.loadCache(environmentId, "pull-request", "v1:list")).toEqual( | ||
| 112 | Option.some('{"entries":[]}'), | ||
| 113 | ); | ||
| 114 | yield* database.clearEnvironmentCache(environmentId); | ||
| 115 | expect((yield* database.loadCache(environmentId, "pull-request", "v1:list"))._tag).toBe( | ||
| 116 | "None", | ||
| 117 | ); | ||
| 118 | expect((yield* database.loadCache(otherEnvironment, "pull-request", "v1:list"))._tag).toBe( | ||
| 119 | "Some", | ||
| 120 | ); | ||
| 121 | }), | ||
| 122 | ), | ||
| 123 | ); | ||
| 124 | |||
| 125 | it.effect( | ||
| 126 | "expires old records and bounds count and payload size without evicting other cache kinds", | ||
| 127 | () => | ||
| 128 | Effect.scoped( | ||
| 129 | Effect.gen(function* () { | ||
| 130 | const sqlite = yield* Effect.promise(sqliteDatabase); | ||
| 131 | const database = yield* MobileDatabase.make; | ||
| 132 | yield* database.saveCache(environmentId, "thread", "thread", 1, "thread"); | ||
| 133 | yield* database.saveCache(environmentId, "pull-request", "old", 1, "old"); | ||
| 134 | sqlite.prepare("UPDATE client_cache SET updated_at = 0 WHERE cache_key = 'old'").run(); | ||
| 135 | expect((yield* database.loadCache(environmentId, "pull-request", "old"))._tag).toBe( | ||
| 136 | "None", | ||
| 137 | ); | ||
| 138 | for (let i = 0; i < 260; i++) | ||
| 139 | yield* database.saveCache(environmentId, "pull-request", `page-${i}`, 1, "page"); | ||
| 140 | expect((yield* database.listCache("pull-request")).length).toBe(256); | ||
| 141 | const large = "x".repeat(4 * 1024 * 1024); | ||
| 142 | for (let i = 0; i < 10; i++) | ||
| 143 | yield* database.saveCache(environmentId, "pull-request", `diff-${i}`, 1, large); | ||
| 144 | const summary = yield* database.inspectCaches; | ||
| 145 | const prs = summary.find((row) => row.kind === "pull-request"); | ||
| 146 | expect(prs?.payloadBytes).toBeLessThanOrEqual(32 * 1024 * 1024); | ||
| 147 | expect(prs?.recordCount).toBeLessThanOrEqual(256); | ||
| 148 | expect((yield* database.loadCache(environmentId, "thread", "thread"))._tag).toBe("Some"); | ||
| 149 | yield* database.saveCache(environmentId, "pull-request", "oversized", 1, large + "x"); | ||
| 150 | expect((yield* database.loadCache(environmentId, "pull-request", "oversized"))._tag).toBe( | ||
| 151 | "None", | ||
| 152 | ); | ||
| 153 | }), | ||
| 154 | ), | ||
| 155 | ); | ||
| 156 | }); |
apps/mobile/src/persistence/mobile-database.ts+49-17| ... | @@ -22,6 +22,7 @@ export const ClientCacheKind = Schema.Literals([ | ... | @@ -22,6 +22,7 @@ export const ClientCacheKind = Schema.Literals([ |
| 22 | "server-config", | 22 | "server-config", |
| 23 | "vcs-refs", | 23 | "vcs-refs", |
| 24 | "project-favicon", | 24 | "project-favicon", |
| 25 | "pull-request", | ||
| 25 | ]); | 26 | ]); |
| 26 | export type ClientCacheKind = typeof ClientCacheKind.Type; | 27 | export type ClientCacheKind = typeof ClientCacheKind.Type; |
| 27 | 28 | ||
| ... | @@ -294,10 +295,12 @@ const makeAvailable = Effect.gen(function* () { | ... | @@ -294,10 +295,12 @@ const makeAvailable = Effect.gen(function* () { |
| 294 | database.getFirstAsync<{ readonly payload: string }>( | 295 | database.getFirstAsync<{ readonly payload: string }>( |
| 295 | `SELECT payload | 296 | `SELECT payload |
| 296 | FROM client_cache | 297 | FROM client_cache |
| 297 | WHERE environment_id = ? AND kind = ? AND cache_key = ?`, | 298 | WHERE environment_id = ? AND kind = ? AND cache_key = ? |
| 299 | AND (kind != 'pull-request' OR updated_at >= ?)`, | ||
| 298 | environmentId, | 300 | environmentId, |
| 299 | kind, | 301 | kind, |
| 300 | cacheKey, | 302 | cacheKey, |
| 303 | Date.now() - 30 * 24 * 60 * 60_000, | ||
| 301 | ), | 304 | ), |
| 302 | catch: databaseError("load-cache"), | 305 | catch: databaseError("load-cache"), |
| 303 | }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), | 306 | }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), |
| ... | @@ -315,22 +318,51 @@ const makeAvailable = Effect.gen(function* () { | ... | @@ -315,22 +318,51 @@ const makeAvailable = Effect.gen(function* () { |
| 315 | saveCache: Effect.fn("MobileDatabase.saveCache")( | 318 | saveCache: Effect.fn("MobileDatabase.saveCache")( |
| 316 | (environmentId, kind, cacheKey, schemaVersion, payload) => | 319 | (environmentId, kind, cacheKey, schemaVersion, payload) => |
| 317 | Effect.tryPromise({ | 320 | Effect.tryPromise({ |
| 318 | try: () => | 321 | try: async () => { |
| 319 | database.runAsync( | 322 | // Diffs can be large. Keep the offline PR cache within 32 MiB and 256 |
| 320 | `INSERT INTO client_cache | 323 | // responses per environment, and don't let one huge diff evict it all. |
| 321 | (environment_id, kind, cache_key, schema_version, payload, updated_at) | 324 | if ( |
| 322 | VALUES (?, ?, ?, ?, ?, ?) | 325 | kind === "pull-request" && |
| 323 | ON CONFLICT (environment_id, kind, cache_key) DO UPDATE SET | 326 | new TextEncoder().encode(payload).length > 4 * 1024 * 1024 |
| 324 | schema_version = excluded.schema_version, | 327 | ) |
| 325 | payload = excluded.payload, | 328 | return; |
| 326 | updated_at = excluded.updated_at`, | 329 | const save = async (transaction: SQLiteDatabase) => { |
| 327 | environmentId, | 330 | await transaction.runAsync( |
| 328 | kind, | 331 | `INSERT INTO client_cache |
| 329 | cacheKey, | 332 | (environment_id, kind, cache_key, schema_version, payload, updated_at) |
| 330 | schemaVersion, | 333 | VALUES (?, ?, ?, ?, ?, ?) |
| 331 | payload, | 334 | ON CONFLICT (environment_id, kind, cache_key) DO UPDATE SET |
| 332 | Date.now(), | 335 | schema_version = excluded.schema_version, |
| 333 | ), | 336 | payload = excluded.payload, |
| 337 | updated_at = excluded.updated_at`, | ||
| 338 | environmentId, | ||
| 339 | kind, | ||
| 340 | cacheKey, | ||
| 341 | schemaVersion, | ||
| 342 | payload, | ||
| 343 | Date.now(), | ||
| 344 | ); | ||
| 345 | if (kind === "pull-request") { | ||
| 346 | await transaction.runAsync( | ||
| 347 | `DELETE FROM client_cache WHERE environment_id = ? AND kind = 'pull-request' | ||
| 348 | AND cache_key IN ( | ||
| 349 | SELECT cache_key FROM ( | ||
| 350 | SELECT cache_key, updated_at, | ||
| 351 | ROW_NUMBER() OVER (ORDER BY updated_at DESC, cache_key) AS position, | ||
| 352 | SUM(LENGTH(CAST(payload AS BLOB))) OVER ( | ||
| 353 | ORDER BY updated_at DESC, cache_key | ||
| 354 | ) AS total_bytes | ||
| 355 | FROM client_cache WHERE environment_id = ? AND kind = 'pull-request' | ||
| 356 | ) WHERE position > 256 OR total_bytes > 33554432 OR updated_at < ? | ||
| 357 | )`, | ||
| 358 | environmentId, | ||
| 359 | environmentId, | ||
| 360 | Date.now() - 30 * 24 * 60 * 60_000, | ||
| 361 | ); | ||
| 362 | } | ||
| 363 | }; | ||
| 364 | await save(database); | ||
| 365 | }, | ||
| 334 | catch: databaseError("save-cache"), | 366 | catch: databaseError("save-cache"), |
| 335 | }).pipe(Effect.asVoid), | 367 | }).pipe(Effect.asVoid), |
| 336 | ), | 368 | ), |
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+2-2| ... | @@ -90,11 +90,11 @@ export function PullRequestMarkdown({ | ... | @@ -90,11 +90,11 @@ export function PullRequestMarkdown({ |
| 90 | data-image-gallery | 90 | data-image-gallery |
| 91 | > | 91 | > |
| 92 | {segments.map((segment) => { | 92 | {segments.map((segment) => { |
| 93 | if (segment.kind === "markdown") { | 93 | if (segment.kind === "markdown" || segment.media === "image") { |
| 94 | return ( | 94 | return ( |
| 95 | <ChatMarkdown | 95 | <ChatMarkdown |
| 96 | key={segment.id} | 96 | key={segment.id} |
| 97 | text={segment.text} | 97 | text={segment.kind === "markdown" ? segment.text : ``} |
| 98 | cwd={cwd} | 98 | cwd={cwd} |
| 99 | threadRef={resolvedThreadRef} | 99 | threadRef={resolvedThreadRef} |
| 100 | pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF} | 100 | pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF} |
apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts deleted-146| ... | @@ -1,146 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; | ||
| 4 | |||
| 5 | describe("pull request body segmentation", () => { | ||
| 6 | it("keeps a plain body as a single markdown run", () => { | ||
| 7 | expect(splitPullRequestBody("## What changed\n\nSome prose.")).toEqual([ | ||
| 8 | { id: "markdown:0", kind: "markdown", text: "## What changed\n\nSome prose." }, | ||
| 9 | ]); | ||
| 10 | }); | ||
| 11 | |||
| 12 | // GitHub writes a dropped image into the body as an `<img>` tag, so a bare attachment link on | ||
| 13 | // its own line is the shape it uses for a video — every one sampled in the wild was one. | ||
| 14 | it("lifts a dropped video out and keeps the prose around it", () => { | ||
| 15 | expect( | ||
| 16 | splitPullRequestBody( | ||
| 17 | "Before\n\nhttps://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-abcdef123456\n\nAfter", | ||
| 18 | ), | ||
| 19 | ).toEqual([ | ||
| 20 | { id: "markdown:0", kind: "markdown", text: "Before" }, | ||
| 21 | { | ||
| 22 | id: "attachment:1", | ||
| 23 | kind: "attachment", | ||
| 24 | url: "https://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-abcdef123456", | ||
| 25 | media: "video", | ||
| 26 | }, | ||
| 27 | { id: "markdown:2", kind: "markdown", text: "After" }, | ||
| 28 | ]); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("names a bare link to a video file a video", () => { | ||
| 32 | expect(splitPullRequestBody("https://example.com/demo.mp4?raw=1")).toEqual([ | ||
| 33 | { | ||
| 34 | id: "attachment:0", | ||
| 35 | kind: "attachment", | ||
| 36 | url: "https://example.com/demo.mp4?raw=1", | ||
| 37 | media: "video", | ||
| 38 | }, | ||
| 39 | ]); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("reads the source out of a video tag, including a multi-line one", () => { | ||
| 43 | expect( | ||
| 44 | splitPullRequestBody( | ||
| 45 | '<video controls>\n <source src="https://example.com/a.webm">\n</video>', | ||
| 46 | ), | ||
| 47 | ).toEqual([ | ||
| 48 | { | ||
| 49 | id: "attachment:0", | ||
| 50 | kind: "attachment", | ||
| 51 | url: "https://example.com/a.webm", | ||
| 52 | media: "video", | ||
| 53 | }, | ||
| 54 | ]); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("leaves a dropped image as markdown so it still renders as an image", () => { | ||
| 58 | const body = ""; | ||
| 59 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 60 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 61 | ]); | ||
| 62 | }); | ||
| 63 | |||
| 64 | it("leaves the img tag GitHub writes for a dropped image as markdown", () => { | ||
| 65 | const body = | ||
| 66 | '<img width="1414" alt="image" src="https://github.com/user-attachments/assets/7195f963-51a9-4331-be74-3e06be760422" />'; | ||
| 67 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 68 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 69 | ]); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("leaves an ordinary link alone, whether it is bare or written as markdown", () => { | ||
| 73 | const body = "https://example.com/page\n\n[the docs](https://example.com/docs)"; | ||
| 74 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 75 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 76 | ]); | ||
| 77 | }); | ||
| 78 | |||
| 79 | it("never lifts a link inside fenced code out of it", () => { | ||
| 80 | const body = "```\nhttps://example.com/demo.mp4\n```"; | ||
| 81 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 82 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 83 | ]); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("does not let a different fence marker close an open fence", () => { | ||
| 87 | const body = "```\n~~~\nhttps://example.com/demo.mp4\n```"; | ||
| 88 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 89 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 90 | ]); | ||
| 91 | }); | ||
| 92 | |||
| 93 | it("keeps a video tag that shares its line with prose as markdown", () => { | ||
| 94 | const body = 'Before <video src="https://example.com/a.mp4"></video> after'; | ||
| 95 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 96 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 97 | ]); | ||
| 98 | }); | ||
| 99 | |||
| 100 | it("keeps the indentation that opens a code block", () => { | ||
| 101 | const body = " const answer = 42;"; | ||
| 102 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 103 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 104 | ]); | ||
| 105 | }); | ||
| 106 | |||
| 107 | it("treats an info-string fence as a nested opener, not a close", () => { | ||
| 108 | const body = "```\n```javascript\nhttps://example.com/demo.mp4\n```"; | ||
| 109 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 110 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 111 | ]); | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("leaves an indented code line alone even when it is only a video link", () => { | ||
| 115 | const body = " https://example.com/demo.mp4"; | ||
| 116 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 117 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 118 | ]); | ||
| 119 | }); | ||
| 120 | |||
| 121 | it("leaves an unclosed video tag as prose without eating the rest of the body", () => { | ||
| 122 | const body = ['<video src="https://example.com/a.mp4">', "still prose", "more prose"].join( | ||
| 123 | "\n", | ||
| 124 | ); | ||
| 125 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 126 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 127 | ]); | ||
| 128 | }); | ||
| 129 | |||
| 130 | it("stays linear when the body is full of unclosed video tags", () => { | ||
| 131 | const body = Array.from( | ||
| 132 | { length: 4_000 }, | ||
| 133 | () => '<video src="https://example.com/a.mp4">', | ||
| 134 | ).join("\n"); | ||
| 135 | const startedAt = performance.now(); | ||
| 136 | expect(splitPullRequestBody(body)).toHaveLength(1); | ||
| 137 | expect(performance.now() - startedAt).toBeLessThan(1_000); | ||
| 138 | }); | ||
| 139 | |||
| 140 | it("refuses a non-http source rather than making a link out of it", () => { | ||
| 141 | const body = '<video src="javascript:alert(1)"></video>'; | ||
| 142 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 143 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 144 | ]); | ||
| 145 | }); | ||
| 146 | }); | ||
apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts+4-143| ... | @@ -4,149 +4,10 @@ import { | ... | @@ -4,149 +4,10 @@ import { |
| 4 | type TextMatch, | 4 | type TextMatch, |
| 5 | } from "~/vendor/mdast-find-and-replace"; | 5 | } from "~/vendor/mdast-find-and-replace"; |
| 6 | 6 | ||
| 7 | /** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ | 7 | export { |
| 8 | export type PullRequestBodySegment = | 8 | splitPullRequestBody, |
| 9 | | { readonly id: string; readonly kind: "markdown"; readonly text: string } | 9 | type PullRequestBodySegment, |
| 10 | | { | 10 | } from "@t3tools/client-runtime/pull-request-markdown"; |
| 11 | readonly id: string; | ||
| 12 | readonly kind: "attachment"; | ||
| 13 | readonly url: string; | ||
| 14 | /** | ||
| 15 | * What the reader can be told the upload is. The uuid says nothing on its own — the type | ||
| 16 | * only appears in the signed redirect GitHub answers with — but the shape does: GitHub | ||
| 17 | * writes a dropped image into the body as an `<img>` tag, so a bare attachment link on its | ||
| 18 | * own line is what it does with a video. Anything else keeps the neutral name. | ||
| 19 | */ | ||
| 20 | readonly media: "video" | "unknown"; | ||
| 21 | }; | ||
| 22 | |||
| 23 | const FENCE_PATTERN = /^\s{0,3}((?:`{3,})|(?:~{3,}))(.*)$/u; | ||
| 24 | /** | ||
| 25 | * How far a `<video>` tag may reach for its closing tag. Real embeds are one to three lines, | ||
| 26 | * and the bound keeps an unclosed tag from making each one rescan the rest of the body. | ||
| 27 | */ | ||
| 28 | const VIDEO_TAG_MAX_LINES = 8; | ||
| 29 | /** Four spaces open an indented code block, so its contents stay verbatim markdown. */ | ||
| 30 | const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/u; | ||
| 31 | const BARE_URL_PATTERN = /^<?(https?:\/\/\S+?)>?$/u; | ||
| 32 | const VIDEO_EXTENSION_PATTERN = /\.(?:mp4|webm|mov|m4v|ogv)(?:$|[?#])/iu; | ||
| 33 | /** A dropped video becomes a bare asset link; a dropped image becomes an `<img>` tag. */ | ||
| 34 | const GITHUB_ASSET_PATTERN = /^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/iu; | ||
| 35 | const VIDEO_TAG_SRC_PATTERN = /<(?:video|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/iu; | ||
| 36 | /** Only a tag that owns its line is an embed; inline, it is prose the renderer should keep. */ | ||
| 37 | const STANDALONE_VIDEO_TAG_PATTERN = /^\s*<video\b/iu; | ||
| 38 | const VIDEO_TAG_END_PATTERN = /<\/video>\s*$/iu; | ||
| 39 | |||
| 40 | /** Anything else — `javascript:`, `data:`, a relative path — is not an upload to link out to. */ | ||
| 41 | function isWebUrl(url: string): boolean { | ||
| 42 | try { | ||
| 43 | const parsed = new URL(url); | ||
| 44 | return parsed.protocol === "https:" || parsed.protocol === "http:"; | ||
| 45 | } catch { | ||
| 46 | return false; | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | function attachmentFromLine(line: string): { url: string; media: "video" | "unknown" } | null { | ||
| 51 | const url = BARE_URL_PATTERN.exec(line.trim())?.[1]; | ||
| 52 | if (url === undefined || !isWebUrl(url)) return null; | ||
| 53 | if (VIDEO_EXTENSION_PATTERN.test(url) || GITHUB_ASSET_PATTERN.test(url)) { | ||
| 54 | return { url, media: "video" }; | ||
| 55 | } | ||
| 56 | return null; | ||
| 57 | } | ||
| 58 | |||
| 59 | /** | ||
| 60 | * Splits a pull request body into markdown runs and the uploads embedded in it, which the | ||
| 61 | * markdown renderer drops: `<video>` is not in its sanitizer's schema, and a bare attachment | ||
| 62 | * link arrives as prose. Two shapes are recognised, both of which GitHub produces itself: a | ||
| 63 | * `<video>` (or `<source>`) tag, and a bare link on its own line to a video file or an | ||
| 64 | * uploaded attachment. Fenced code is copied through untouched so a snippet that happens to | ||
| 65 | * contain a link is never lifted out of it. | ||
| 66 | */ | ||
| 67 | export function splitPullRequestBody(body: string): ReadonlyArray<PullRequestBodySegment> { | ||
| 68 | const segments: PullRequestBodySegment[] = []; | ||
| 69 | const markdown: string[] = []; | ||
| 70 | let openFence: string | null = null; | ||
| 71 | |||
| 72 | // Blank lines around a run are dropped, but never leading spaces: four of them open an | ||
| 73 | // indented code block, so trimming a run would silently turn code into prose. | ||
| 74 | const flushMarkdown = () => { | ||
| 75 | const text = markdown.join("\n").replace(/^\n+/u, "").replace(/\s+$/u, ""); | ||
| 76 | markdown.length = 0; | ||
| 77 | if (text.trim().length > 0) { | ||
| 78 | segments.push({ id: `markdown:${segments.length}`, kind: "markdown", text }); | ||
| 79 | } | ||
| 80 | }; | ||
| 81 | |||
| 82 | const lines = body.split("\n"); | ||
| 83 | for (let index = 0; index < lines.length; index += 1) { | ||
| 84 | const line = lines[index]!; | ||
| 85 | const fenceMatch = FENCE_PATTERN.exec(line); | ||
| 86 | if (fenceMatch !== null) { | ||
| 87 | const fence = fenceMatch[1]!; | ||
| 88 | // A fence closes only on the same marker, at least as long as the one that opened it, | ||
| 89 | // and with nothing after it: `~~~` cannot end a ``` block, and ```` ```ts ```` is an | ||
| 90 | // info string opening a nested fence, not a close. | ||
| 91 | const closes = | ||
| 92 | openFence !== null && | ||
| 93 | fence[0] === openFence[0] && | ||
| 94 | fence.length >= openFence.length && | ||
| 95 | fenceMatch[2]!.trim().length === 0; | ||
| 96 | if (openFence === null) { | ||
| 97 | openFence = fence; | ||
| 98 | } else if (closes) { | ||
| 99 | openFence = null; | ||
| 100 | } | ||
| 101 | markdown.push(line); | ||
| 102 | continue; | ||
| 103 | } | ||
| 104 | if (openFence !== null || INDENTED_CODE_PATTERN.test(line)) { | ||
| 105 | markdown.push(line); | ||
| 106 | continue; | ||
| 107 | } | ||
| 108 | |||
| 109 | const bareAttachment = attachmentFromLine(line); | ||
| 110 | if (bareAttachment !== null) { | ||
| 111 | flushMarkdown(); | ||
| 112 | segments.push({ id: `attachment:${segments.length}`, kind: "attachment", ...bareAttachment }); | ||
| 113 | continue; | ||
| 114 | } | ||
| 115 | |||
| 116 | if (!STANDALONE_VIDEO_TAG_PATTERN.test(line)) { | ||
| 117 | markdown.push(line); | ||
| 118 | continue; | ||
| 119 | } | ||
| 120 | // A tag can span lines, so look ahead for its close — bounded, and without building the | ||
| 121 | // joined text until one is actually found. | ||
| 122 | const lastCandidate = Math.min(index + VIDEO_TAG_MAX_LINES, lines.length) - 1; | ||
| 123 | let cursor = index; | ||
| 124 | while (cursor < lastCandidate && !VIDEO_TAG_END_PATTERN.test(lines[cursor]!)) { | ||
| 125 | cursor += 1; | ||
| 126 | } | ||
| 127 | const source = VIDEO_TAG_END_PATTERN.test(lines[cursor]!) | ||
| 128 | ? VIDEO_TAG_SRC_PATTERN.exec(lines.slice(index, cursor + 1).join("\n"))?.[1] | ||
| 129 | : undefined; | ||
| 130 | if (source !== undefined && isWebUrl(source)) { | ||
| 131 | flushMarkdown(); | ||
| 132 | segments.push({ | ||
| 133 | id: `attachment:${segments.length}`, | ||
| 134 | kind: "attachment", | ||
| 135 | url: source, | ||
| 136 | // The author wrote the tag, so this one is a video whatever the URL looks like. | ||
| 137 | media: "video", | ||
| 138 | }); | ||
| 139 | index = cursor; | ||
| 140 | } else { | ||
| 141 | // Unclosed, or nothing linkable in it: prose. Only this line is consumed, so the | ||
| 142 | // lines that were looked at are still parsed on their own terms. | ||
| 143 | markdown.push(line); | ||
| 144 | } | ||
| 145 | } | ||
| 146 | |||
| 147 | flushMarkdown(); | ||
| 148 | return segments; | ||
| 149 | } | ||
| 150 | 11 | ||
| 151 | const AUTOLINK_CANDIDATE_PATTERN = /#[1-9]\d*|[0-9a-f]{40}/giu; | 12 | const AUTOLINK_CANDIDATE_PATTERN = /#[1-9]\d*|[0-9a-f]{40}/giu; |
| 152 | const AUTOLINK_WORD_CHARACTER_PATTERN = /[A-Za-z0-9_]/u; | 13 | const AUTOLINK_WORD_CHARACTER_PATTERN = /[A-Za-z0-9_]/u; |
docs/user/source-control.md+4| ... | @@ -158,6 +158,10 @@ to read its description, activity, commits, and changed files. Line comments sta | ... | @@ -158,6 +158,10 @@ to read its description, activity, commits, and changed files. Line comments sta |
| 158 | until you submit the review. **Work on PR** opens a chat with the review attached; sending | 158 | until you submit the review. **Work on PR** opens a chat with the review attached; sending |
| 159 | the first message prepares a worktree from the review's branch. | 159 | the first message prepares a worktree from the review's branch. |
| 160 | 160 | ||
| 161 | Previously loaded lists, review details, guides, and diff pages remain readable on mobile | ||
| 162 | without a connection, including after restarting the app. Saved content refreshes when the | ||
| 163 | environment reconnects; submitting changes requires a connection. | ||
| 164 | |||
| 161 | Enable **Remove agent credits when merging** in Settings → Source Control to remove recognized | 165 | Enable **Remove agent credits when merging** in Settings → Source Control to remove recognized |
| 162 | agent co-author and generated-by lines from GitHub merge and squash commit messages. Human | 166 | agent co-author and generated-by lines from GitHub merge and squash commit messages. Human |
| 163 | co-authors stay credited. The setting is off by default and projects can override it. It also | 167 | co-authors stay credited. The setting is off by default and projects can override it. It also |
packages/client-runtime/package.json+8| ... | @@ -402,6 +402,14 @@ | ... | @@ -402,6 +402,14 @@ |
| 402 | "./review-guide-tour": { | 402 | "./review-guide-tour": { |
| 403 | "types": "./src/reviewGuideTour.ts", | 403 | "types": "./src/reviewGuideTour.ts", |
| 404 | "default": "./src/reviewGuideTour.ts" | 404 | "default": "./src/reviewGuideTour.ts" |
| 405 | }, | ||
| 406 | "./state/query-persistence": { | ||
| 407 | "types": "./src/state/queryPersistence.ts", | ||
| 408 | "default": "./src/state/queryPersistence.ts" | ||
| 409 | }, | ||
| 410 | "./pull-request-markdown": { | ||
| 411 | "types": "./src/pullRequestMarkdown.ts", | ||
| 412 | "default": "./src/pullRequestMarkdown.ts" | ||
| 405 | } | 413 | } |
| 406 | }, | 414 | }, |
| 407 | "scripts": { | 415 | "scripts": { |
packages/client-runtime/src/pullRequestMarkdown.test.ts created+166| ... | @@ -0,0 +1,166 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { splitPullRequestBody } from "./pullRequestMarkdown.ts"; | ||
| 4 | |||
| 5 | describe("pull request body segmentation", () => { | ||
| 6 | it("keeps a plain body as a single markdown run", () => { | ||
| 7 | expect(splitPullRequestBody("## What changed\n\nSome prose.")).toEqual([ | ||
| 8 | { id: "markdown:0", kind: "markdown", text: "## What changed\n\nSome prose." }, | ||
| 9 | ]); | ||
| 10 | }); | ||
| 11 | |||
| 12 | // GitHub writes a dropped image into the body as an `<img>` tag, so a bare attachment link on | ||
| 13 | // its own line is the shape it uses for a video — every one sampled in the wild was one. | ||
| 14 | it("lifts a dropped video out and keeps the prose around it", () => { | ||
| 15 | expect( | ||
| 16 | splitPullRequestBody( | ||
| 17 | "Before\n\nhttps://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-abcdef123456\n\nAfter", | ||
| 18 | ), | ||
| 19 | ).toEqual([ | ||
| 20 | { id: "markdown:0", kind: "markdown", text: "Before" }, | ||
| 21 | { | ||
| 22 | id: "attachment:1", | ||
| 23 | kind: "attachment", | ||
| 24 | url: "https://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-abcdef123456", | ||
| 25 | media: "video", | ||
| 26 | }, | ||
| 27 | { id: "markdown:2", kind: "markdown", text: "After" }, | ||
| 28 | ]); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("names a bare link to a video file a video", () => { | ||
| 32 | expect(splitPullRequestBody("https://example.com/demo.mp4?raw=1")).toEqual([ | ||
| 33 | { | ||
| 34 | id: "attachment:0", | ||
| 35 | kind: "attachment", | ||
| 36 | url: "https://example.com/demo.mp4?raw=1", | ||
| 37 | media: "video", | ||
| 38 | }, | ||
| 39 | ]); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("reads the source out of a video tag, including a multi-line one", () => { | ||
| 43 | expect( | ||
| 44 | splitPullRequestBody( | ||
| 45 | '<video controls>\n <source src="https://example.com/a.webm">\n</video>', | ||
| 46 | ), | ||
| 47 | ).toEqual([ | ||
| 48 | { | ||
| 49 | id: "attachment:0", | ||
| 50 | kind: "attachment", | ||
| 51 | url: "https://example.com/a.webm", | ||
| 52 | media: "video", | ||
| 53 | }, | ||
| 54 | ]); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("leaves a dropped image as markdown so it still renders as an image", () => { | ||
| 58 | const body = ""; | ||
| 59 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 60 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 61 | ]); | ||
| 62 | }); | ||
| 63 | |||
| 64 | it("leaves the img tag GitHub writes for a dropped image as markdown", () => { | ||
| 65 | const body = | ||
| 66 | '<img width="1414" alt="image" src="https://github.com/user-attachments/assets/7195f963-51a9-4331-be74-3e06be760422" />'; | ||
| 67 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 68 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 69 | ]); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("leaves an ordinary link alone, whether it is bare or written as markdown", () => { | ||
| 73 | const body = "https://example.com/page\n\n[the docs](https://example.com/docs)"; | ||
| 74 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 75 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 76 | ]); | ||
| 77 | }); | ||
| 78 | |||
| 79 | it("never lifts a link inside fenced code out of it", () => { | ||
| 80 | const body = "```\nhttps://example.com/demo.mp4\n```"; | ||
| 81 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 82 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 83 | ]); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("does not let a different fence marker close an open fence", () => { | ||
| 87 | const body = "```\n~~~\nhttps://example.com/demo.mp4\n```"; | ||
| 88 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 89 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 90 | ]); | ||
| 91 | }); | ||
| 92 | |||
| 93 | it("keeps a video tag that shares its line with prose as markdown", () => { | ||
| 94 | const body = 'Before <video src="https://example.com/a.mp4"></video> after'; | ||
| 95 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 96 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 97 | ]); | ||
| 98 | }); | ||
| 99 | |||
| 100 | it("keeps the indentation that opens a code block", () => { | ||
| 101 | const body = " const answer = 42;"; | ||
| 102 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 103 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 104 | ]); | ||
| 105 | }); | ||
| 106 | |||
| 107 | it("treats an info-string fence as a nested opener, not a close", () => { | ||
| 108 | const body = "```\n```javascript\nhttps://example.com/demo.mp4\n```"; | ||
| 109 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 110 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 111 | ]); | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("leaves an indented code line alone even when it is only a video link", () => { | ||
| 115 | const body = " https://example.com/demo.mp4"; | ||
| 116 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 117 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 118 | ]); | ||
| 119 | }); | ||
| 120 | |||
| 121 | it("leaves an unclosed video tag as prose without eating the rest of the body", () => { | ||
| 122 | const body = ['<video src="https://example.com/a.mp4">', "still prose", "more prose"].join( | ||
| 123 | "\n", | ||
| 124 | ); | ||
| 125 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 126 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 127 | ]); | ||
| 128 | }); | ||
| 129 | |||
| 130 | it("stays linear when the body is full of unclosed video tags", () => { | ||
| 131 | const body = Array.from( | ||
| 132 | { length: 4_000 }, | ||
| 133 | () => '<video src="https://example.com/a.mp4">', | ||
| 134 | ).join("\n"); | ||
| 135 | const startedAt = performance.now(); | ||
| 136 | expect(splitPullRequestBody(body)).toHaveLength(1); | ||
| 137 | expect(performance.now() - startedAt).toBeLessThan(1_000); | ||
| 138 | }); | ||
| 139 | |||
| 140 | it("refuses a non-http source rather than making a link out of it", () => { | ||
| 141 | const body = '<video src="javascript:alert(1)"></video>'; | ||
| 142 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 143 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 144 | ]); | ||
| 145 | }); | ||
| 146 | }); | ||
| 147 | |||
| 148 | describe("media on custom hosts", () => { | ||
| 149 | it.each([ | ||
| 150 | ["https://drop.example.com/capture.PNG?token=abc#preview", "image"], | ||
| 151 | ["[Recording](https://drop.example.com/recording.mov)", "video"], | ||
| 152 | ["<https://drop.example.com/shot.webp>", "image"], | ||
| 153 | ])("recognizes %s without a request", (body, media) => { | ||
| 154 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 155 | expect.objectContaining({ kind: "attachment", media }), | ||
| 156 | ]); | ||
| 157 | }); | ||
| 158 | |||
| 159 | it("keeps inline links and media examples in code as prose", () => { | ||
| 160 | const body = | ||
| 161 | "See [recording](https://drop.example.com/a.mp4) for context.\n\n```md\nhttps://drop.example.com/a.png\n```"; | ||
| 162 | expect(splitPullRequestBody(body)).toEqual([ | ||
| 163 | { id: "markdown:0", kind: "markdown", text: body }, | ||
| 164 | ]); | ||
| 165 | }); | ||
| 166 | }); | ||
packages/client-runtime/src/pullRequestMarkdown.ts created+146| ... | @@ -0,0 +1,146 @@ | ||
| 1 | /** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ | ||
| 2 | export type PullRequestBodySegment = | ||
| 3 | | { readonly id: string; readonly kind: "markdown"; readonly text: string } | ||
| 4 | | { | ||
| 5 | readonly id: string; | ||
| 6 | readonly kind: "attachment"; | ||
| 7 | readonly url: string; | ||
| 8 | /** | ||
| 9 | * What the reader can be told the upload is. The uuid says nothing on its own — the type | ||
| 10 | * only appears in the signed redirect GitHub answers with — but the shape does: GitHub | ||
| 11 | * writes a dropped image into the body as an `<img>` tag, so a bare attachment link on its | ||
| 12 | * own line is what it does with a video. Anything else keeps the neutral name. | ||
| 13 | */ | ||
| 14 | readonly media: "video" | "image" | "unknown"; | ||
| 15 | }; | ||
| 16 | |||
| 17 | const FENCE_PATTERN = /^\s{0,3}((?:`{3,})|(?:~{3,}))(.*)$/u; | ||
| 18 | /** | ||
| 19 | * How far a `<video>` tag may reach for its closing tag. Real embeds are one to three lines, | ||
| 20 | * and the bound keeps an unclosed tag from making each one rescan the rest of the body. | ||
| 21 | */ | ||
| 22 | const VIDEO_TAG_MAX_LINES = 8; | ||
| 23 | /** Four spaces open an indented code block, so its contents stay verbatim markdown. */ | ||
| 24 | const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/u; | ||
| 25 | const BARE_URL_PATTERN = /^<?(https?:\/\/\S+?)>?$/u; | ||
| 26 | const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp|avif|heic|heif|svg)(?:$|[?#])/iu; | ||
| 27 | const MARKDOWN_LINK_PATTERN = /^\[[^\]]*\]\((https?:\/\/[^\s)]+)\)$/u; | ||
| 28 | const VIDEO_EXTENSION_PATTERN = /\.(?:mp4|webm|mov|m4v|ogv)(?:$|[?#])/iu; | ||
| 29 | /** A dropped video becomes a bare asset link; a dropped image becomes an `<img>` tag. */ | ||
| 30 | const GITHUB_ASSET_PATTERN = /^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/iu; | ||
| 31 | const VIDEO_TAG_SRC_PATTERN = /<(?:video|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/iu; | ||
| 32 | /** Only a tag that owns its line is an embed; inline, it is prose the renderer should keep. */ | ||
| 33 | const STANDALONE_VIDEO_TAG_PATTERN = /^\s*<video\b/iu; | ||
| 34 | const VIDEO_TAG_END_PATTERN = /<\/video>\s*$/iu; | ||
| 35 | |||
| 36 | /** Anything else — `javascript:`, `data:`, a relative path — is not an upload to link out to. */ | ||
| 37 | function isWebUrl(url: string): boolean { | ||
| 38 | try { | ||
| 39 | const parsed = new URL(url); | ||
| 40 | return parsed.protocol === "https:" || parsed.protocol === "http:"; | ||
| 41 | } catch { | ||
| 42 | return false; | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | function attachmentFromLine( | ||
| 47 | line: string, | ||
| 48 | ): { url: string; media: "video" | "image" | "unknown" } | null { | ||
| 49 | const url = | ||
| 50 | BARE_URL_PATTERN.exec(line.trim())?.[1] ?? MARKDOWN_LINK_PATTERN.exec(line.trim())?.[1]; | ||
| 51 | if (url === undefined || !isWebUrl(url)) return null; | ||
| 52 | if (VIDEO_EXTENSION_PATTERN.test(url) || GITHUB_ASSET_PATTERN.test(url)) { | ||
| 53 | return { url, media: "video" }; | ||
| 54 | } | ||
| 55 | if (IMAGE_EXTENSION_PATTERN.test(url)) return { url, media: "image" }; | ||
| 56 | return null; | ||
| 57 | } | ||
| 58 | |||
| 59 | /** | ||
| 60 | * Lifts standalone media links and video tags out of prose for both clients. | ||
| 61 | * Ordinary links and code examples remain markdown. Images already embedded | ||
| 62 | * with markdown or HTML keep their original renderer. | ||
| 63 | */ | ||
| 64 | export function splitPullRequestBody(body: string): ReadonlyArray<PullRequestBodySegment> { | ||
| 65 | const segments: PullRequestBodySegment[] = []; | ||
| 66 | const markdown: string[] = []; | ||
| 67 | let openFence: string | null = null; | ||
| 68 | |||
| 69 | // Blank lines around a run are dropped, but never leading spaces: four of them open an | ||
| 70 | // indented code block, so trimming a run would silently turn code into prose. | ||
| 71 | const flushMarkdown = () => { | ||
| 72 | const text = markdown.join("\n").replace(/^\n+/u, "").replace(/\s+$/u, ""); | ||
| 73 | markdown.length = 0; | ||
| 74 | if (text.trim().length > 0) { | ||
| 75 | segments.push({ id: `markdown:${segments.length}`, kind: "markdown", text }); | ||
| 76 | } | ||
| 77 | }; | ||
| 78 | |||
| 79 | const lines = body.split("\n"); | ||
| 80 | for (let index = 0; index < lines.length; index += 1) { | ||
| 81 | const line = lines[index]!; | ||
| 82 | const fenceMatch = FENCE_PATTERN.exec(line); | ||
| 83 | if (fenceMatch !== null) { | ||
| 84 | const fence = fenceMatch[1]!; | ||
| 85 | // A fence closes only on the same marker, at least as long as the one that opened it, | ||
| 86 | // and with nothing after it: `~~~` cannot end a ``` block, and ```` ```ts ```` is an | ||
| 87 | // info string opening a nested fence, not a close. | ||
| 88 | const closes = | ||
| 89 | openFence !== null && | ||
| 90 | fence[0] === openFence[0] && | ||
| 91 | fence.length >= openFence.length && | ||
| 92 | fenceMatch[2]!.trim().length === 0; | ||
| 93 | if (openFence === null) { | ||
| 94 | openFence = fence; | ||
| 95 | } else if (closes) { | ||
| 96 | openFence = null; | ||
| 97 | } | ||
| 98 | markdown.push(line); | ||
| 99 | continue; | ||
| 100 | } | ||
| 101 | if (openFence !== null || INDENTED_CODE_PATTERN.test(line)) { | ||
| 102 | markdown.push(line); | ||
| 103 | continue; | ||
| 104 | } | ||
| 105 | |||
| 106 | const bareAttachment = attachmentFromLine(line); | ||
| 107 | if (bareAttachment !== null) { | ||
| 108 | flushMarkdown(); | ||
| 109 | segments.push({ id: `attachment:${segments.length}`, kind: "attachment", ...bareAttachment }); | ||
| 110 | continue; | ||
| 111 | } | ||
| 112 | |||
| 113 | if (!STANDALONE_VIDEO_TAG_PATTERN.test(line)) { | ||
| 114 | markdown.push(line); | ||
| 115 | continue; | ||
| 116 | } | ||
| 117 | // A tag can span lines, so look ahead for its close — bounded, and without building the | ||
| 118 | // joined text until one is actually found. | ||
| 119 | const lastCandidate = Math.min(index + VIDEO_TAG_MAX_LINES, lines.length) - 1; | ||
| 120 | let cursor = index; | ||
| 121 | while (cursor < lastCandidate && !VIDEO_TAG_END_PATTERN.test(lines[cursor]!)) { | ||
| 122 | cursor += 1; | ||
| 123 | } | ||
| 124 | const source = VIDEO_TAG_END_PATTERN.test(lines[cursor]!) | ||
| 125 | ? VIDEO_TAG_SRC_PATTERN.exec(lines.slice(index, cursor + 1).join("\n"))?.[1] | ||
| 126 | : undefined; | ||
| 127 | if (source !== undefined && isWebUrl(source)) { | ||
| 128 | flushMarkdown(); | ||
| 129 | segments.push({ | ||
| 130 | id: `attachment:${segments.length}`, | ||
| 131 | kind: "attachment", | ||
| 132 | url: source, | ||
| 133 | // The author wrote the tag, so this one is a video whatever the URL looks like. | ||
| 134 | media: "video", | ||
| 135 | }); | ||
| 136 | index = cursor; | ||
| 137 | } else { | ||
| 138 | // Unclosed, or nothing linkable in it: prose. Only this line is consumed, so the | ||
| 139 | // lines that were looked at are still parsed on their own terms. | ||
| 140 | markdown.push(line); | ||
| 141 | } | ||
| 142 | } | ||
| 143 | |||
| 144 | flushMarkdown(); | ||
| 145 | return segments; | ||
| 146 | } | ||
packages/client-runtime/src/state/pullRequestStatsPersistence.ts created+43| ... | @@ -0,0 +1,43 @@ | ||
| 1 | import { | ||
| 2 | PullRequestDiffStat, | ||
| 3 | PullRequestListStatsInput, | ||
| 4 | type PullRequestListStatsResult, | ||
| 5 | type PullRequestRef, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import { persistQuery, type QueryPersistence } from "./queryPersistence.ts"; | ||
| 11 | |||
| 12 | const requestKey = Schema.fromJsonString(Schema.Tuple([Schema.String, PullRequestListStatsInput])); | ||
| 13 | const statKey = (ref: PullRequestRef) => | ||
| 14 | JSON.stringify(["pull-request-stat", ref.projectId, ref.host ?? "", ref.repository, ref.number]); | ||
| 15 | |||
| 16 | /** Visible rows are batched differently after a restart, so store their counts individually. */ | ||
| 17 | export function persistPullRequestStats(): QueryPersistence<PullRequestListStatsResult> { | ||
| 18 | const row = persistQuery(PullRequestDiffStat); | ||
| 19 | return { | ||
| 20 | load: (environmentId, key) => | ||
| 21 | Effect.gen(function* () { | ||
| 22 | const [, input] = yield* Schema.decodeEffect(requestKey)(key); | ||
| 23 | const rows = yield* Effect.forEach(input.refs, (ref) => | ||
| 24 | row.load(environmentId, statKey(ref)), | ||
| 25 | ); | ||
| 26 | const stats = rows.flatMap((value) => (Option.isSome(value) ? [value.value] : [])); | ||
| 27 | return stats.length ? Option.some({ stats }) : Option.none(); | ||
| 28 | }).pipe(Effect.orElseSucceed(() => Option.none())), | ||
| 29 | save: (environmentId, key, result) => | ||
| 30 | Effect.gen(function* () { | ||
| 31 | const [, input] = yield* Schema.decodeEffect(requestKey)(key); | ||
| 32 | yield* Effect.forEach(result.stats, (stat) => { | ||
| 33 | const ref = input.refs.find( | ||
| 34 | (ref) => | ||
| 35 | ref.projectId === stat.projectId && | ||
| 36 | ref.repository === stat.repository && | ||
| 37 | ref.number === stat.number, | ||
| 38 | ); | ||
| 39 | return ref ? row.save(environmentId, statKey(ref), stat) : Effect.void; | ||
| 40 | }); | ||
| 41 | }).pipe(Effect.ignore), | ||
| 42 | }; | ||
| 43 | } | ||
packages/client-runtime/src/state/pullRequests.test.ts+46| ... | @@ -4,6 +4,7 @@ import { vi } from "vite-plus/test"; | ... | @@ -4,6 +4,7 @@ import { vi } from "vite-plus/test"; |
| 4 | import { | 4 | import { |
| 5 | EnvironmentId, | 5 | EnvironmentId, |
| 6 | AuthSourceControlWriteScope, | 6 | AuthSourceControlWriteScope, |
| 7 | AuthOrchestrationOperateScope, | ||
| 7 | ProjectId, | 8 | ProjectId, |
| 8 | PullRequestOperationError, | 9 | PullRequestOperationError, |
| 9 | WS_METHODS, | 10 | WS_METHODS, |
| ... | @@ -2082,3 +2083,48 @@ it.effect("denies a routed write when only the origin has source-control permiss | ... | @@ -2082,3 +2083,48 @@ it.effect("denies a routed write when only the origin has source-control permiss |
| 2082 | }), | 2083 | }), |
| 2083 | ), | 2084 | ), |
| 2084 | ); | 2085 | ); |
| 2086 | |||
| 2087 | it.effect("publishes the accepted guide run before the follow-up read finishes", () => | ||
| 2088 | Effect.scoped( | ||
| 2089 | Effect.gen(function* () { | ||
| 2090 | const accepted = { | ||
| 2091 | guide: null, | ||
| 2092 | currentHeadSha: "head", | ||
| 2093 | run: { _tag: "Running" as const, headSha: "head", startedAt: "2026-10-11T00:00:00.000Z" }, | ||
| 2094 | }; | ||
| 2095 | let reads = 0; | ||
| 2096 | const refreshStarted = yield* Latch.make(); | ||
| 2097 | const client = { | ||
| 2098 | [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.never, | ||
| 2099 | [WS_METHODS.pullRequestsReviewGuide]: () => | ||
| 2100 | Effect.gen(function* () { | ||
| 2101 | if (++reads > 1) { | ||
| 2102 | yield* refreshStarted.open; | ||
| 2103 | return yield* Effect.never; | ||
| 2104 | } | ||
| 2105 | return { guide: null, run: null, currentHeadSha: "head" }; | ||
| 2106 | }), | ||
| 2107 | [WS_METHODS.pullRequestsGenerateReviewGuide]: () => Effect.succeed(accepted), | ||
| 2108 | } as unknown as WsRpcProtocolClient; | ||
| 2109 | const { atoms, registry } = yield* makeTestRuntime(client); | ||
| 2110 | const target = { | ||
| 2111 | environmentId: TARGET.environmentId, | ||
| 2112 | input: { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 1 }, | ||
| 2113 | }; | ||
| 2114 | registry.update( | ||
| 2115 | grantedSessions(target.environmentId), | ||
| 2116 | AsyncResult.map((session) => ({ | ||
| 2117 | ...session, | ||
| 2118 | scopes: [...(session.scopes ?? []), AuthOrchestrationOperateScope], | ||
| 2119 | })), | ||
| 2120 | ); | ||
| 2121 | const query = atoms.reviewGuide(target); | ||
| 2122 | registry.mount(query); | ||
| 2123 | yield* AtomRegistry.getResult(registry, query, { suspendOnWaiting: true }); | ||
| 2124 | const result = yield* Effect.promise(() => atoms.generateReviewGuide.run(registry, target)); | ||
| 2125 | expect(result._tag).toBe("Success"); | ||
| 2126 | yield* refreshStarted.await; | ||
| 2127 | expect(Option.getOrNull(AsyncResult.value(registry.get(query)))).toEqual(accepted); | ||
| 2128 | }), | ||
| 2129 | ), | ||
| 2130 | ); |
packages/client-runtime/src/state/pullRequests.ts+28-1| ... | @@ -1,5 +1,14 @@ | ... | @@ -1,5 +1,14 @@ |
| 1 | import { | 1 | import { |
| 2 | WS_METHODS, | 2 | WS_METHODS, |
| 3 | PullRequestActivity, | ||
| 4 | PullRequestDetail as PullRequestDetailSchema, | ||
| 5 | PullRequestPreview, | ||
| 6 | PullRequestReviewGuideResult, | ||
| 7 | PullRequestListResult, | ||
| 8 | PullRequestChecks, | ||
| 9 | PullRequestDiffResult, | ||
| 10 | PullRequestFilesViewedResult, | ||
| 11 | PullRequestLinkedThreadsResult, | ||
| 3 | type EnvironmentId, | 12 | type EnvironmentId, |
| 4 | type PullRequestActor, | 13 | type PullRequestActor, |
| 5 | type PullRequestActionInput, | 14 | type PullRequestActionInput, |
| ... | @@ -13,6 +22,9 @@ import { | ... | @@ -13,6 +22,9 @@ import { |
| 13 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| 14 | import * as Data from "effect/Data"; | 23 | import * as Data from "effect/Data"; |
| 15 | import * as Effect from "effect/Effect"; | 24 | import * as Effect from "effect/Effect"; |
| 25 | import * as Schema from "effect/Schema"; | ||
| 26 | import { persistPullRequestStats } from "./pullRequestStatsPersistence.ts"; | ||
| 27 | import { persistQuery } from "./queryPersistence.ts"; | ||
| 16 | import * as Option from "effect/Option"; | 28 | import * as Option from "effect/Option"; |
| 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 29 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 18 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | 30 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; |
| ... | @@ -172,6 +184,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -172,6 +184,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 172 | const activity = writableQueryFamily( | 184 | const activity = writableQueryFamily( |
| 173 | createEnvironmentRpcQueryAtomFamily(runtime, { | 185 | createEnvironmentRpcQueryAtomFamily(runtime, { |
| 174 | label: "environment-data:pull-requests:activity", | 186 | label: "environment-data:pull-requests:activity", |
| 187 | persistence: persistQuery(PullRequestActivity), | ||
| 175 | tag: WS_METHODS.pullRequestsActivity, | 188 | tag: WS_METHODS.pullRequestsActivity, |
| 176 | execute: (input) => routedRequest(WS_METHODS.pullRequestsActivity, input), | 189 | execute: (input) => routedRequest(WS_METHODS.pullRequestsActivity, input), |
| 177 | staleTimeMs: 60_000, | 190 | staleTimeMs: 60_000, |
| ... | @@ -181,6 +194,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -181,6 +194,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 181 | const detail = writableQueryFamily( | 194 | const detail = writableQueryFamily( |
| 182 | createEnvironmentRpcQueryAtomFamily(runtime, { | 195 | createEnvironmentRpcQueryAtomFamily(runtime, { |
| 183 | label: "environment-data:pull-requests:detail", | 196 | label: "environment-data:pull-requests:detail", |
| 197 | persistence: persistQuery(PullRequestDetailSchema), | ||
| 184 | tag: WS_METHODS.pullRequestsDetail, | 198 | tag: WS_METHODS.pullRequestsDetail, |
| 185 | execute: (input) => routedRequest(WS_METHODS.pullRequestsDetail, input), | 199 | execute: (input) => routedRequest(WS_METHODS.pullRequestsDetail, input), |
| 186 | staleTimeMs: 60_000, | 200 | staleTimeMs: 60_000, |
| ... | @@ -190,6 +204,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -190,6 +204,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 190 | const preview = writableQueryFamily( | 204 | const preview = writableQueryFamily( |
| 191 | createEnvironmentRpcQueryAtomFamily(runtime, { | 205 | createEnvironmentRpcQueryAtomFamily(runtime, { |
| 192 | label: "environment-data:pull-requests:preview", | 206 | label: "environment-data:pull-requests:preview", |
| 207 | persistence: persistQuery(PullRequestPreview), | ||
| 193 | tag: WS_METHODS.pullRequestsPreview, | 208 | tag: WS_METHODS.pullRequestsPreview, |
| 194 | execute: (input) => routedRequest(WS_METHODS.pullRequestsPreview, input), | 209 | execute: (input) => routedRequest(WS_METHODS.pullRequestsPreview, input), |
| 195 | staleTimeMs: 60_000, | 210 | staleTimeMs: 60_000, |
| ... | @@ -208,6 +223,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -208,6 +223,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 208 | const reviewGuide = writableQueryFamily( | 223 | const reviewGuide = writableQueryFamily( |
| 209 | createEnvironmentRpcQueryAtomFamily(runtime, { | 224 | createEnvironmentRpcQueryAtomFamily(runtime, { |
| 210 | label: "environment-data:pull-requests:review-guide", | 225 | label: "environment-data:pull-requests:review-guide", |
| 226 | persistence: persistQuery(PullRequestReviewGuideResult), | ||
| 211 | tag: WS_METHODS.pullRequestsReviewGuide, | 227 | tag: WS_METHODS.pullRequestsReviewGuide, |
| 212 | execute: (input) => routedRequest(WS_METHODS.pullRequestsReviewGuide, input), | 228 | execute: (input) => routedRequest(WS_METHODS.pullRequestsReviewGuide, input), |
| 213 | staleTimeMs: 60_000, | 229 | staleTimeMs: 60_000, |
| ... | @@ -226,6 +242,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -226,6 +242,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 226 | refreshes, | 242 | refreshes, |
| 227 | linkedThreads: createEnvironmentRpcQueryAtomFamily(runtime, { | 243 | linkedThreads: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 228 | label: "environment-data:pull-requests:linked-threads", | 244 | label: "environment-data:pull-requests:linked-threads", |
| 245 | persistence: persistQuery(PullRequestLinkedThreadsResult), | ||
| 229 | tag: WS_METHODS.pullRequestsLinkedThreads, | 246 | tag: WS_METHODS.pullRequestsLinkedThreads, |
| 230 | staleTimeMs: 0, | 247 | staleTimeMs: 0, |
| 231 | refreshIntervalMs: 10_000, | 248 | refreshIntervalMs: 10_000, |
| ... | @@ -233,6 +250,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -233,6 +250,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 233 | }), | 250 | }), |
| 234 | list: createEnvironmentRpcQueryAtomFamily(runtime, { | 251 | list: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 235 | label: "environment-data:pull-requests:list", | 252 | label: "environment-data:pull-requests:list", |
| 253 | persistence: persistQuery(PullRequestListResult), | ||
| 236 | tag: WS_METHODS.pullRequestsList, | 254 | tag: WS_METHODS.pullRequestsList, |
| 237 | staleTimeMs: 30_000, | 255 | staleTimeMs: 30_000, |
| 238 | refreshTrigger: ({ environmentId, input }) => | 256 | refreshTrigger: ({ environmentId, input }) => |
| ... | @@ -246,6 +264,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -246,6 +264,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 246 | */ | 264 | */ |
| 247 | listStats: createEnvironmentRpcQueryAtomFamily(runtime, { | 265 | listStats: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 248 | label: "environment-data:pull-requests:list-stats", | 266 | label: "environment-data:pull-requests:list-stats", |
| 267 | persistence: persistPullRequestStats(), | ||
| 249 | tag: WS_METHODS.pullRequestsListStats, | 268 | tag: WS_METHODS.pullRequestsListStats, |
| 250 | staleTimeMs: 60_000, | 269 | staleTimeMs: 60_000, |
| 251 | refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), | 270 | refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), |
| ... | @@ -254,6 +273,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -254,6 +273,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 254 | preview, | 273 | preview, |
| 255 | checks: createEnvironmentRpcQueryAtomFamily(runtime, { | 274 | checks: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 256 | label: "environment-data:pull-requests:checks", | 275 | label: "environment-data:pull-requests:checks", |
| 276 | persistence: persistQuery(Schema.NullOr(PullRequestChecks)), | ||
| 257 | tag: WS_METHODS.pullRequestsChecks, | 277 | tag: WS_METHODS.pullRequestsChecks, |
| 258 | execute: (input) => routedRequest(WS_METHODS.pullRequestsChecks, input), | 278 | execute: (input) => routedRequest(WS_METHODS.pullRequestsChecks, input), |
| 259 | staleTimeMs: 45_000, | 279 | staleTimeMs: 45_000, |
| ... | @@ -273,6 +293,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -273,6 +293,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 273 | }), | 293 | }), |
| 274 | diff: createEnvironmentQueryAtomFamily(runtime, { | 294 | diff: createEnvironmentQueryAtomFamily(runtime, { |
| 275 | label: "environment-data:pull-requests:diff", | 295 | label: "environment-data:pull-requests:diff", |
| 296 | persistence: persistQuery(PullRequestDiffResult), | ||
| 276 | staleTimeMs: 60_000, | 297 | staleTimeMs: 60_000, |
| 277 | execute: (input: PullRequestDiffInput) => | 298 | execute: (input: PullRequestDiffInput) => |
| 278 | Effect.gen(function* () { | 299 | Effect.gen(function* () { |
| ... | @@ -310,6 +331,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -310,6 +331,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 310 | }), | 331 | }), |
| 311 | filesViewed: createEnvironmentRpcQueryAtomFamily(runtime, { | 332 | filesViewed: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 312 | label: "environment-data:pull-requests:files-viewed", | 333 | label: "environment-data:pull-requests:files-viewed", |
| 334 | persistence: persistQuery(PullRequestFilesViewedResult), | ||
| 313 | tag: WS_METHODS.pullRequestsFilesViewed, | 335 | tag: WS_METHODS.pullRequestsFilesViewed, |
| 314 | execute: (input) => routedRequest(WS_METHODS.pullRequestsFilesViewed, input), | 336 | execute: (input) => routedRequest(WS_METHODS.pullRequestsFilesViewed, input), |
| 315 | staleTimeMs: 15_000, | 337 | staleTimeMs: 15_000, |
| ... | @@ -517,7 +539,12 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -517,7 +539,12 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 517 | label: "environment-data:pull-requests:generate-review-guide", | 539 | label: "environment-data:pull-requests:generate-review-guide", |
| 518 | tag: WS_METHODS.pullRequestsGenerateReviewGuide, | 540 | tag: WS_METHODS.pullRequestsGenerateReviewGuide, |
| 519 | execute: (input) => routedRequest(WS_METHODS.pullRequestsGenerateReviewGuide, input), | 541 | execute: (input) => routedRequest(WS_METHODS.pullRequestsGenerateReviewGuide, input), |
| 520 | onSuccess: (target, registry) => Effect.sync(() => registry.refresh(reviewGuide(target))), | 542 | onSuccess: (target, registry, result) => |
| 543 | Effect.sync(() => { | ||
| 544 | // The command returns the accepted run. Publish it before starting ends so readers | ||
| 545 | // never fall back to the empty/failed state while the follow-up query is in flight. | ||
| 546 | updateCached(registry, reviewGuide(target), () => result, true); | ||
| 547 | }), | ||
| 521 | }), | 548 | }), |
| 522 | /** Read when the label menu opens, and kept for a minute, like the reviewer candidates. */ | 549 | /** Read when the label menu opens, and kept for a minute, like the reviewer candidates. */ |
| 523 | labelCandidates, | 550 | labelCandidates, |
packages/client-runtime/src/state/queryPersistence.test.ts created+71| ... | @@ -0,0 +1,71 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { EnvironmentId, ProjectId } from "@t3tools/contracts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | import { EnvironmentQueryStore, persistQuery } from "./queryPersistence.ts"; | ||
| 7 | |||
| 8 | import { persistPullRequestStats } from "./pullRequestStatsPersistence.ts"; | ||
| 9 | |||
| 10 | const env = EnvironmentId.make("one"); | ||
| 11 | const other = EnvironmentId.make("two"); | ||
| 12 | const persisted = persistQuery(Schema.Struct({ title: Schema.String })); | ||
| 13 | |||
| 14 | function store(records = new Map<string, string>()) { | ||
| 15 | return { | ||
| 16 | load: (environmentId: EnvironmentId, key: string) => | ||
| 17 | Effect.sync(() => Option.fromUndefinedOr(records.get(`${environmentId}:${key}`))), | ||
| 18 | save: (environmentId: EnvironmentId, key: string, payload: string) => | ||
| 19 | Effect.sync(() => { | ||
| 20 | records.set(`${environmentId}:${key}`, payload); | ||
| 21 | }), | ||
| 22 | }; | ||
| 23 | } | ||
| 24 | |||
| 25 | describe("query persistence", () => { | ||
| 26 | it.effect("round trips responses without mixing environments or query scopes", () => | ||
| 27 | Effect.gen(function* () { | ||
| 28 | yield* persisted.save(env, "open", { title: "saved" }); | ||
| 29 | expect(yield* persisted.load(env, "open")).toEqual(Option.some({ title: "saved" })); | ||
| 30 | expect(yield* persisted.load(other, "open")).toEqual(Option.none()); | ||
| 31 | expect(yield* persisted.load(env, "closed")).toEqual(Option.none()); | ||
| 32 | }).pipe(Effect.provideService(EnvironmentQueryStore, store())), | ||
| 33 | ); | ||
| 34 | |||
| 35 | it.effect("ignores corrupt or incompatible records", () => | ||
| 36 | Effect.gen(function* () { | ||
| 37 | expect(yield* persisted.load(env, "broken")).toEqual(Option.none()); | ||
| 38 | expect(yield* persisted.load(env, "incompatible")).toEqual(Option.none()); | ||
| 39 | }).pipe( | ||
| 40 | Effect.provideService( | ||
| 41 | EnvironmentQueryStore, | ||
| 42 | store( | ||
| 43 | new Map([ | ||
| 44 | ["one:broken", "not json"], | ||
| 45 | ["one:incompatible", '{"title":42}'], | ||
| 46 | ]), | ||
| 47 | ), | ||
| 48 | ), | ||
| 49 | ), | ||
| 50 | ); | ||
| 51 | |||
| 52 | it.effect("does not persist on platforms without a query store", () => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | yield* persisted.save(env, "open", { title: "saved" }); | ||
| 55 | expect(yield* persisted.load(env, "open")).toEqual(Option.none()); | ||
| 56 | }), | ||
| 57 | ); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it.effect("restores line counts when visible PR batches change", () => | ||
| 61 | Effect.gen(function* () { | ||
| 62 | const cache = persistPullRequestStats(); | ||
| 63 | const first = { projectId: ProjectId.make("project"), repository: "owner/repo", number: 1 }; | ||
| 64 | const second = { ...first, number: 2 }; | ||
| 65 | const stat = { ...first, additions: 42, deletions: 3 }; | ||
| 66 | yield* cache.save(env, JSON.stringify(["stats", { refs: [first, second] }]), { stats: [stat] }); | ||
| 67 | const key = JSON.stringify(["stats", { refs: [first] }]); | ||
| 68 | expect(yield* cache.load(env, key)).toEqual(Option.some({ stats: [stat] })); | ||
| 69 | expect(yield* cache.load(other, key)).toEqual(Option.none()); | ||
| 70 | }).pipe(Effect.provideService(EnvironmentQueryStore, store())), | ||
| 71 | ); | ||
packages/client-runtime/src/state/queryPersistence.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | |||
| 7 | /** Platforms opt in to storing selected query responses beside their other offline data. */ | ||
| 8 | export class EnvironmentQueryStore extends Context.Reference<{ | ||
| 9 | readonly load: ( | ||
| 10 | environmentId: EnvironmentId, | ||
| 11 | key: string, | ||
| 12 | ) => Effect.Effect<Option.Option<string>>; | ||
| 13 | readonly save: ( | ||
| 14 | environmentId: EnvironmentId, | ||
| 15 | key: string, | ||
| 16 | payload: string, | ||
| 17 | ) => Effect.Effect<void>; | ||
| 18 | } | null>("@t3tools/client-runtime/EnvironmentQueryStore", { | ||
| 19 | defaultValue: () => null, | ||
| 20 | }) {} | ||
| 21 | |||
| 22 | export interface QueryPersistence<A> { | ||
| 23 | readonly load: (environmentId: EnvironmentId, key: string) => Effect.Effect<Option.Option<A>>; | ||
| 24 | readonly save: (environmentId: EnvironmentId, key: string, value: A) => Effect.Effect<void>; | ||
| 25 | } | ||
| 26 | |||
| 27 | export function persistQuery<A, I>(schema: Schema.Codec<A, I>): QueryPersistence<A> { | ||
| 28 | const codec = Schema.fromJsonString(Schema.toCodecJson(schema)); | ||
| 29 | return { | ||
| 30 | load: (environmentId, key) => | ||
| 31 | Effect.gen(function* () { | ||
| 32 | const store = yield* EnvironmentQueryStore; | ||
| 33 | if (store === null) return Option.none<A>(); | ||
| 34 | const stored = yield* store.load(environmentId, key); | ||
| 35 | if (Option.isNone(stored)) return Option.none<A>(); | ||
| 36 | return yield* Schema.decodeUnknownEffect(codec)(stored.value).pipe( | ||
| 37 | Effect.map(Option.some), | ||
| 38 | Effect.catch(() => Effect.succeedNone), | ||
| 39 | ); | ||
| 40 | }), | ||
| 41 | save: (environmentId, key, value) => | ||
| 42 | Effect.gen(function* () { | ||
| 43 | const store = yield* EnvironmentQueryStore; | ||
| 44 | if (store === null) return; | ||
| 45 | const payload = yield* Schema.encodeEffect(codec)(value); | ||
| 46 | yield* store.save(environmentId, key, payload); | ||
| 47 | }).pipe(Effect.catch(() => Effect.logWarning("Offline query cache save failed"))), | ||
| 48 | }; | ||
| 49 | } | ||
packages/client-runtime/src/state/runtime.test.ts+96-2| ... | @@ -26,6 +26,7 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; | ... | @@ -26,6 +26,7 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; |
| 26 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 26 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 27 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; | 27 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; |
| 28 | import type * as RpcSession from "../rpc/session.ts"; | 28 | import type * as RpcSession from "../rpc/session.ts"; |
| 29 | import type { QueryPersistence } from "./queryPersistence.ts"; | ||
| 29 | import { | 30 | import { |
| 30 | environmentRpcKey, | 31 | environmentRpcKey, |
| 31 | createAtomCommandScheduler, | 32 | createAtomCommandScheduler, |
| ... | @@ -85,9 +86,16 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" | ... | @@ -85,9 +86,16 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" |
| 85 | { | 86 | { |
| 86 | refreshTrigger, | 87 | refreshTrigger, |
| 87 | idleTtlMs, | 88 | idleTtlMs, |
| 88 | }: { readonly refreshTrigger?: Atom.Atom<unknown>; readonly idleTtlMs?: number } = {}, | 89 | persistence, |
| 90 | initialState, | ||
| 91 | }: { | ||
| 92 | readonly refreshTrigger?: Atom.Atom<unknown>; | ||
| 93 | readonly idleTtlMs?: number; | ||
| 94 | readonly persistence?: QueryPersistence<A>; | ||
| 95 | readonly initialState?: SupervisorConnectionState; | ||
| 96 | } = {}, | ||
| 89 | ) { | 97 | ) { |
| 90 | const supervisorState = yield* SubscriptionRef.make(queryConnectionState()); | 98 | const supervisorState = yield* SubscriptionRef.make(initialState ?? queryConnectionState()); |
| 91 | const supervisorSession = yield* SubscriptionRef.make(Option.some(QUERY_RPC_SESSION)); | 99 | const supervisorSession = yield* SubscriptionRef.make(Option.some(QUERY_RPC_SESSION)); |
| 92 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | 100 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ |
| 93 | target: QUERY_ENVIRONMENT, | 101 | target: QUERY_ENVIRONMENT, |
| ... | @@ -115,6 +123,7 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" | ... | @@ -115,6 +123,7 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" |
| 115 | const family = createEnvironmentQueryAtomFamily(runtime, { | 123 | const family = createEnvironmentQueryAtomFamily(runtime, { |
| 116 | label: "test.environment-query", | 124 | label: "test.environment-query", |
| 117 | staleTimeMs: 60_000, | 125 | staleTimeMs: 60_000, |
| 126 | ...(persistence === undefined ? {} : { persistence }), | ||
| 118 | execute: () => execute, | 127 | execute: () => execute, |
| 119 | ...(refreshTrigger === undefined ? {} : { refreshTrigger: () => refreshTrigger }), | 128 | ...(refreshTrigger === undefined ? {} : { refreshTrigger: () => refreshTrigger }), |
| 120 | ...(idleTtlMs === undefined ? {} : { idleTtlMs }), | 129 | ...(idleTtlMs === undefined ? {} : { idleTtlMs }), |
| ... | @@ -1126,3 +1135,88 @@ describe("runtime command runner", () => { | ... | @@ -1126,3 +1135,88 @@ describe("runtime command runner", () => { |
| 1126 | registry.dispose(); | 1135 | registry.dispose(); |
| 1127 | }); | 1136 | }); |
| 1128 | }); | 1137 | }); |
| 1138 | |||
| 1139 | describe("persisted environment queries", () => { | ||
| 1140 | it.effect( | ||
| 1141 | "restores cached data in a new registry while offline, then refreshes on reconnect", | ||
| 1142 | () => | ||
| 1143 | Effect.scoped( | ||
| 1144 | Effect.gen(function* () { | ||
| 1145 | let saved = Option.none<string>(); | ||
| 1146 | const persistence: QueryPersistence<string> = { | ||
| 1147 | load: () => Effect.sync(() => saved), | ||
| 1148 | save: (_environmentId, _key, value) => | ||
| 1149 | Effect.sync(() => { | ||
| 1150 | saved = Option.some(value); | ||
| 1151 | }), | ||
| 1152 | }; | ||
| 1153 | const first = yield* makeEnvironmentQueryHarness(Effect.succeed("first"), { | ||
| 1154 | persistence, | ||
| 1155 | }); | ||
| 1156 | const firstRegistry = yield* mountEnvironmentQuery(first.atom); | ||
| 1157 | expect(yield* readEnvironmentQuery(firstRegistry, first.atom)).toBe("first"); | ||
| 1158 | expect(saved).toEqual(Option.some("first")); | ||
| 1159 | |||
| 1160 | const restarted = yield* makeEnvironmentQueryHarness(Effect.succeed("updated"), { | ||
| 1161 | persistence, | ||
| 1162 | initialState: queryConnectionState({ phase: "offline", network: "offline" }), | ||
| 1163 | }); | ||
| 1164 | const registry = yield* mountEnvironmentQuery(restarted.atom); | ||
| 1165 | expect(yield* readEnvironmentQuery(registry, restarted.atom)).toBe("first"); | ||
| 1166 | yield* SubscriptionRef.set(restarted.supervisorState, queryConnectionState()); | ||
| 1167 | yield* Effect.yieldNow; | ||
| 1168 | expect(yield* readEnvironmentQuery(registry, restarted.atom)).toBe("updated"); | ||
| 1169 | expect(saved).toEqual(Option.some("updated")); | ||
| 1170 | }), | ||
| 1171 | ), | ||
| 1172 | ); | ||
| 1173 | |||
| 1174 | it.effect("keeps cached content available when revalidation fails", () => | ||
| 1175 | Effect.scoped( | ||
| 1176 | Effect.gen(function* () { | ||
| 1177 | const harness = yield* makeEnvironmentQueryHarness( | ||
| 1178 | Effect.fail(new TestQueryError({ message: "failed" })), | ||
| 1179 | { | ||
| 1180 | persistence: { load: () => Effect.succeedSome("cached"), save: () => Effect.void }, | ||
| 1181 | }, | ||
| 1182 | ); | ||
| 1183 | const registry = yield* mountEnvironmentQuery(harness.atom); | ||
| 1184 | yield* Effect.result(readEnvironmentQuery(registry, harness.atom)); | ||
| 1185 | expect(Option.getOrNull(AsyncResult.value(registry.get(harness.atom)))).toBe("cached"); | ||
| 1186 | }), | ||
| 1187 | ), | ||
| 1188 | ); | ||
| 1189 | |||
| 1190 | it.effect("shows persisted data before a live request finishes", () => | ||
| 1191 | Effect.scoped( | ||
| 1192 | Effect.gen(function* () { | ||
| 1193 | const response = yield* Deferred.make<string>(); | ||
| 1194 | const seeded = yield* Deferred.make<void>(); | ||
| 1195 | const refreshed = yield* Deferred.make<void>(); | ||
| 1196 | const harness = yield* makeEnvironmentQueryHarness(Deferred.await(response), { | ||
| 1197 | persistence: { | ||
| 1198 | load: () => Effect.succeedSome("cached"), | ||
| 1199 | save: () => Effect.void, | ||
| 1200 | }, | ||
| 1201 | }); | ||
| 1202 | const registry = yield* mountEnvironmentQuery(harness.atom); | ||
| 1203 | const unsubscribe = registry.subscribe( | ||
| 1204 | harness.atom, | ||
| 1205 | (value) => { | ||
| 1206 | if (Option.getOrNull(AsyncResult.value(value)) === "cached") | ||
| 1207 | Deferred.doneUnsafe(seeded, Exit.void); | ||
| 1208 | if (Option.getOrNull(AsyncResult.value(value)) === "fresh") | ||
| 1209 | Deferred.doneUnsafe(refreshed, Exit.void); | ||
| 1210 | }, | ||
| 1211 | { immediate: true }, | ||
| 1212 | ); | ||
| 1213 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1214 | yield* Deferred.await(seeded); | ||
| 1215 | expect(Option.getOrNull(AsyncResult.value(registry.get(harness.atom)))).toBe("cached"); | ||
| 1216 | yield* Deferred.succeed(response, "fresh"); | ||
| 1217 | yield* Deferred.await(refreshed); | ||
| 1218 | expect(yield* readEnvironmentQuery(registry, harness.atom)).toBe("fresh"); | ||
| 1219 | }), | ||
| 1220 | ), | ||
| 1221 | ); | ||
| 1222 | }); |
packages/client-runtime/src/state/runtime.ts+62-27| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { QueryPersistence } from "./queryPersistence.ts"; | ||
| 1 | import { createCommandPermissions } from "./commandPermissions.ts"; | 2 | import { createCommandPermissions } from "./commandPermissions.ts"; |
| 2 | import { followStreamInEnvironment } from "./environmentStreams.ts"; | 3 | import { followStreamInEnvironment } from "./environmentStreams.ts"; |
| 3 | export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; | 4 | export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; |
| ... | @@ -65,6 +66,7 @@ interface EnvironmentQueryAtomOptions<Input, A, E, R> extends Omit< | ... | @@ -65,6 +66,7 @@ interface EnvironmentQueryAtomOptions<Input, A, E, R> extends Omit< |
| 65 | input: Input, | 66 | input: Input, |
| 66 | emit: (value: A) => Effect.Effect<void>, | 67 | emit: (value: A) => Effect.Effect<void>, |
| 67 | ) => Effect.Effect<A, E, R>; | 68 | ) => Effect.Effect<A, E, R>; |
| 69 | readonly persistence?: QueryPersistence<A>; | ||
| 68 | readonly staleTimeMs?: number; | 70 | readonly staleTimeMs?: number; |
| 69 | readonly idleTtlMs?: number; | 71 | readonly idleTtlMs?: number; |
| 70 | readonly refreshIntervalMs?: number; | 72 | readonly refreshIntervalMs?: number; |
| ... | @@ -550,10 +552,6 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>( | ... | @@ -550,10 +552,6 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>( |
| 550 | const connection = Option.getOrNull( | 552 | const connection = Option.getOrNull( |
| 551 | AsyncResult.value(get(connectionAtom(target.environmentId))), | 553 | AsyncResult.value(get(connectionAtom(target.environmentId))), |
| 552 | ); | 554 | ); |
| 553 | if (connection === null) { | ||
| 554 | return Effect.never; | ||
| 555 | } | ||
| 556 | const [connectionState, session] = connection; | ||
| 557 | const emit = (value: A) => | 555 | const emit = (value: A) => |
| 558 | Effect.sync(() => get.setSelf(AsyncResult.success(value, { waiting: true }))).pipe( | 556 | Effect.sync(() => get.setSelf(AsyncResult.success(value, { waiting: true }))).pipe( |
| 559 | // A value set while the atom builds wins over the build's own | 557 | // A value set while the atom builds wins over the build's own |
| ... | @@ -562,29 +560,64 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>( | ... | @@ -562,29 +560,64 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>( |
| 562 | // inside the build) so the settled result arrives asynchronously. | 560 | // inside the build) so the settled result arrives asynchronously. |
| 563 | Effect.andThen(Effect.promise(() => Promise.resolve())), | 561 | Effect.andThen(Effect.promise(() => Promise.resolve())), |
| 564 | ); | 562 | ); |
| 565 | switch (connectionState.phase) { | 563 | const execute = (): Effect.Effect< |
| 566 | case "connected": | 564 | A, |
| 567 | return Option.isSome(session) | 565 | | E |
| 568 | ? runInEnvironment(target.environmentId, options.execute(target.input, emit)) | 566 | | ConnectionAttemptError |
| 569 | : Effect.never; | 567 | | EnvironmentRegistry.EnvironmentNotRegisteredError |
| 570 | case "connecting": | 568 | | EnvironmentRpcUnavailableError, |
| 571 | case "backoff": | 569 | | EnvironmentRegistry.EnvironmentRegistry |
| 572 | return Effect.never; | 570 | | AtomRegistry.AtomRegistry |
| 573 | case "available": | 571 | | Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor> |
| 574 | case "offline": | 572 | > => { |
| 575 | case "blocked": | 573 | if (connection === null) return Effect.never; |
| 576 | if (connectionState.lastFailure !== null) { | 574 | const [connectionState, session] = connection; |
| 577 | return Effect.fail(connectionState.lastFailure); | 575 | switch (connectionState.phase) { |
| 578 | } | 576 | case "connected": |
| 579 | return Effect.fail( | 577 | return Option.isSome(session) |
| 580 | new EnvironmentRpcUnavailableError({ | 578 | ? runInEnvironment(target.environmentId, options.execute(target.input, emit)) |
| 581 | environmentId: target.environmentId, | 579 | : Effect.never; |
| 582 | message: `Environment ${target.environmentId} is ${ | 580 | case "connecting": |
| 583 | connectionState.phase === "available" ? "not connected" : connectionState.phase | 581 | case "backoff": |
| 584 | }.`, | 582 | return Effect.never; |
| 585 | }), | 583 | case "available": |
| 586 | ); | 584 | case "offline": |
| 587 | } | 585 | case "blocked": |
| 586 | if (connectionState.lastFailure !== null) { | ||
| 587 | return Effect.fail(connectionState.lastFailure); | ||
| 588 | } | ||
| 589 | return Effect.fail( | ||
| 590 | new EnvironmentRpcUnavailableError({ | ||
| 591 | environmentId: target.environmentId, | ||
| 592 | message: `Environment ${target.environmentId} is ${ | ||
| 593 | connectionState.phase === "available" ? "not connected" : connectionState.phase | ||
| 594 | }.`, | ||
| 595 | }), | ||
| 596 | ); | ||
| 597 | } | ||
| 598 | }; | ||
| 599 | if (!options.persistence) return execute(); | ||
| 600 | const persistence = options.persistence; | ||
| 601 | const cacheKey = JSON.stringify([options.label, target.input]); | ||
| 602 | const previous = Option.flatMap( | ||
| 603 | get.self<AsyncResult.AsyncResult<A, unknown>>(), | ||
| 604 | AsyncResult.value, | ||
| 605 | ); | ||
| 606 | return Effect.gen(function* () { | ||
| 607 | const cached = Option.isSome(previous) | ||
| 608 | ? previous | ||
| 609 | : yield* persistence.load(target.environmentId, cacheKey); | ||
| 610 | if ( | ||
| 611 | Option.isSome(cached) && | ||
| 612 | (connection === null || connection[0].phase !== "connected") | ||
| 613 | ) { | ||
| 614 | return cached.value; | ||
| 615 | } | ||
| 616 | if (Option.isNone(previous) && Option.isSome(cached)) yield* emit(cached.value); | ||
| 617 | return yield* execute().pipe( | ||
| 618 | Effect.tap((value) => persistence.save(target.environmentId, cacheKey, value)), | ||
| 619 | ); | ||
| 620 | }); | ||
| 588 | }) | 621 | }) |
| 589 | .pipe( | 622 | .pipe( |
| 590 | Atom.swr({ | 623 | Atom.swr({ |
| ... | @@ -675,6 +708,7 @@ export function createEnvironmentRpcQueryAtomFamily< | ... | @@ -675,6 +708,7 @@ export function createEnvironmentRpcQueryAtomFamily< |
| 675 | EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError, | 708 | EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError, |
| 676 | EnvironmentSupervisor.EnvironmentSupervisor | EnvironmentRegistry.EnvironmentRegistry | 709 | EnvironmentSupervisor.EnvironmentSupervisor | EnvironmentRegistry.EnvironmentRegistry |
| 677 | >; | 710 | >; |
| 711 | readonly persistence?: QueryPersistence<EnvironmentRpcSuccess<TTag>>; | ||
| 678 | readonly staleTimeMs?: number; | 712 | readonly staleTimeMs?: number; |
| 679 | readonly idleTtlMs?: number; | 713 | readonly idleTtlMs?: number; |
| 680 | readonly refreshIntervalMs?: number; | 714 | readonly refreshIntervalMs?: number; |
| ... | @@ -686,6 +720,7 @@ export function createEnvironmentRpcQueryAtomFamily< | ... | @@ -686,6 +720,7 @@ export function createEnvironmentRpcQueryAtomFamily< |
| 686 | ) { | 720 | ) { |
| 687 | return createEnvironmentQueryAtomFamily(runtime, { | 721 | return createEnvironmentQueryAtomFamily(runtime, { |
| 688 | label: options.label, | 722 | label: options.label, |
| 723 | ...(options.persistence === undefined ? {} : { persistence: options.persistence }), | ||
| 689 | ...(options.staleTimeMs === undefined ? {} : { staleTimeMs: options.staleTimeMs }), | 724 | ...(options.staleTimeMs === undefined ? {} : { staleTimeMs: options.staleTimeMs }), |
| 690 | ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), | 725 | ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), |
| 691 | ...(options.refreshIntervalMs === undefined | 726 | ...(options.refreshIntervalMs === undefined |