Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/mobile/src/connection/pull-request-cache-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { EnvironmentQueryStore } from "@t3tools/client-runtime/state/query-persistence";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import { MobileDatabase } from "../persistence/mobile-database";

export const layer = Layer.effect(
EnvironmentQueryStore,
Effect.gen(function* () {
const database = yield* MobileDatabase;
return {
load: (environmentId, key) =>
database
.loadCache(environmentId, "pull-request", `v1:${key}`)
.pipe(Effect.catch(() => Effect.succeedNone)),
save: (environmentId, key, payload) =>
database
.saveCache(environmentId, "pull-request", `v1:${key}`, 1, payload)
.pipe(Effect.catch(() => Effect.logWarning("Offline query database save failed"))),
} satisfies NonNullable<typeof EnvironmentQueryStore.Service>;
}),
);
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ export function PullRequestActionToolbar(props: {
const agent = usePullRequestAgentActions(props.params, props.pr, props.filePath);
const canReview = permissions.verdicts.length > 0;
const pendingReview = canReview && draft.comments.length > 0;
const canOpenThread = agent.threads.length > 0 || agent.canAsk;
const showAgentControls = agent.canAsk || !permissions.connected;
const canOpenThread = agent.threads.length > 0 || showAgentControls;
const threadLabel = agent.threads.length ? "Continue thread" : "Work on PR";
const primaryLabel = pendingReview ? `Submit review · ${draft.comments.length}` : threadLabel;
const chooseThread = agent.threads.length > 1;
Expand All @@ -142,7 +143,7 @@ export function PullRequestActionToolbar(props: {
// Reserve the native outer margins, custom-button padding, and item gaps.
// UIKit needs this space to keep every action out of its overflow menu.
// Duo's side rail uses an intrinsic icon item instead.
const sideControls = Number(agent.canAsk) + 1;
const sideControls = Number(showAgentControls) + 1;
const primaryWidth = verticalControls
? undefined
: Math.max(
Expand All @@ -166,7 +167,7 @@ export function PullRequestActionToolbar(props: {
className="flex-row items-center justify-between gap-3 border-t border-border-subtle bg-screen px-4 pt-2"
style={{ paddingBottom: Math.max(insets.bottom, 8) }}
>
{agent.canAsk ? (
{showAgentControls ? (
<ControlPillMenu
title="AI helpers"
actions={agentActions.map((action) => agent.menuAction(action.task))}
Expand Down Expand Up @@ -259,7 +260,7 @@ export function PullRequestActionToolbar(props: {
/>
)
) : null;
const agentControl = agent.canAsk ? (
const agentControl = showAgentControls ? (
<NativeHeaderToolbar.Menu
identifier="pull-request-agent"
width={controlSize}
Expand Down
113 changes: 56 additions & 57 deletions apps/mobile/src/features/pull-requests/PullRequestDescription.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { useMemo, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from "react-native";
import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown";
import {
PullRequestImageAttachment,
PullRequestMedia,
PullRequestMediaContext,
} from "./PullRequestMedia";
import { PullRequestSectionHeading } from "./PullRequestSectionHeading";
import { useContext, useMemo, useState } from "react";
import { Pressable, ScrollView, StyleSheet, View } from "react-native";
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
import { Image } from "expo-image";
import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia";
import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
import { nativeMarkdownWithHtml } from "@t3tools/mobile-markdown-text/markdown";
import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
import type { FilePreviewItem } from "../../components/FilePreviewModal";
import { PullRequestMarkdown } from "./PullRequestMarkdown";
import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent";

Expand All @@ -21,31 +28,39 @@ export function PullRequestDescription(props: {
}) {
const { expanded, onExpandedChange } = props;
const [bodyHeight, setBodyHeight] = useState(0);
const [preview, setPreview] = useState<FilePreviewSource | null>(null);
const openingUri = preview && "uri" in preview ? preview.uri : null;
const mediaContext = useContext(PullRequestMediaContext);
const { themeVariables: colors } = useAppearancePreferences();
const content = useMemo(() => {
const document = nativeMarkdownWithHtml(
parseMarkdownWithOptions(props.markdown, { gfm: true, html: true, math: false }),
(markdown) => parseMarkdownWithOptions(markdown, { gfm: true, html: false, math: false }),
);
return pullRequestDescriptionContent(document);
return pullRequestDescriptionContent(document, splitPullRequestBody(props.markdown));
}, [props.markdown]);
const gallery = useMemo(
const gallery = useMemo<FilePreviewItem[]>(
() =>
content.attachments.map((attachment) => ({
kind: "image" as const,
uri: attachment.uri,
name: new URL(attachment.uri).pathname.split("/").pop() || "Image",
})),
[content],
content.attachments
.filter((attachment) => attachment.kind === "image")
.map((attachment) => {
const fetchUrl = githubMediaFetchUrl(attachment.uri);
const name = new URL(attachment.uri).pathname.split("/").pop() || "Image";
return mediaContext && fetchUrl
? {
kind: "image",
name,
environmentId: mediaContext.environmentId,
resource: { _tag: "github-media", cwd: mediaContext.cwd, url: fetchUrl },
}
: { kind: "image", name, uri: attachment.uri };
}),
[content, mediaContext],
);
const canCollapse = bodyHeight > COLLAPSED_HEIGHT;
const collapsed = canCollapse && !expanded;
return (
<View className="gap-5">
<View className="gap-3">
<Text className="text-base font-t3-semibold">Description</Text>
<PullRequestSectionHeading>Description</PullRequestSectionHeading>
{props.markdown.trim() ? (
<View className="overflow-hidden">
<View
Expand Down Expand Up @@ -130,58 +145,42 @@ export function PullRequestDescription(props: {
</View>
{content.attachments.length ? (
<View className="gap-3">
<Text className="text-base font-t3-semibold">Attachments</Text>
<PullRequestSectionHeading>Attachments</PullRequestSectionHeading>
<ScrollView
horizontal
keyboardShouldPersistTaps="always"
scrollsToTop={false}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 12 }}
>
{content.attachments.map((attachment) => (
<Pressable
key={attachment.uri}
accessibilityRole="imagebutton"
accessibilityLabel={`View attachment: ${attachment.caption}`}
accessibilityState={{ busy: openingUri === attachment.uri }}
disabled={preview !== null}
onPress={() =>
setPreview({
kind: "image",
uri: attachment.uri,
name: gallery.find((item) => item.uri === attachment.uri)?.name ?? "Image",
gallery,
})
}
className="gap-2 active:opacity-70"
style={{ width: 176 }}
>
<View className="overflow-hidden rounded-2xl border border-border-subtle bg-grouped-card">
<Image
source={{ uri: attachment.uri }}
style={{ width: 174, height: 112 }}
contentFit="contain"
cachePolicy="memory-disk"
/>
{openingUri === attachment.uri ? (
<View
pointerEvents="none"
style={StyleSheet.absoluteFill}
className="items-center justify-center bg-black/30"
>
<ActivityIndicator color="white" />
</View>
) : null}
</View>
<Text numberOfLines={1} className="text-xs text-foreground-muted">
{attachment.caption}
</Text>
</Pressable>
))}
{content.attachments.map((attachment) =>
attachment.kind !== "image" ? (
<PullRequestMedia
key={attachment.uri}
url={attachment.uri}
kind={attachment.kind}
caption={attachment.caption}
compact
/>
) : (
<PullRequestImageAttachment
key={attachment.uri}
url={attachment.uri}
caption={attachment.caption}
source={
gallery[
content.attachments
.filter((item) => item.kind === "image")
.indexOf(attachment)
]!
}
gallery={gallery}
/>
),
)}
</ScrollView>
</View>
) : null}
<FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />
</View>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss
const resolveLabel = thread?.isResolved ? "Unresolve" : "Resolve";
return (
<PullRequestMarkdownRepository
params={params}
value={detail.data?.provider === "github" ? detail.data.url : null}
>
<KeyboardAvoidingView automaticOffset behavior="height" className="flex-1 bg-screen">
Expand Down
23 changes: 12 additions & 11 deletions apps/mobile/src/features/pull-requests/PullRequestFiles.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PullRequestSectionHeading } from "./PullRequestSectionHeading";
import { AnimatedLegendList } from "@legendapp/list/reanimated";
import Animated from "react-native-reanimated";
import { PR_ROW_LAYOUT, PR_ROW_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations";
Expand Down Expand Up @@ -167,15 +168,15 @@ export function usePullRequestFilesContent(props: {
accessibilityState={{ busy: true }}
className="min-h-17 flex-row items-center gap-2 py-3"
>
<View className="min-w-0 flex-1 gap-2">
<Text className="text-base font-t3-semibold">
{fileCount === undefined
? "Changed files"
: `${fileCount} changed ${fileCount === 1 ? "file" : "files"}`}
</Text>
{filter.hideTestFiles ? (
<View className="min-w-0 flex-1 gap-1">
<PullRequestSectionHeading>Changed files</PullRequestSectionHeading>
{fileCount !== undefined ? (
<Text className="text-2xs text-foreground-muted">
{fileCount} {fileCount === 1 ? "file" : "files"}
</Text>
) : (
<View className="h-2.5 w-28 rounded bg-subtle-strong" />
) : null}
)}
</View>
{summary?.additions !== undefined && summary.deletions !== undefined ? (
<PullRequestDiffStats
Expand All @@ -196,14 +197,14 @@ export function usePullRequestFilesContent(props: {
<>
<View className="min-h-17 flex-row items-center gap-2 py-3">
<View className="min-w-0 flex-1 gap-1">
<Text className="text-base font-t3-semibold">
<PullRequestSectionHeading>Changed files</PullRequestSectionHeading>
<Text className="text-2xs text-foreground-muted">
{parsed.fileCount} {filter.hiddenCount ? "visible" : "changed"}{" "}
{parsed.fileCount === 1 ? "file" : "files"}
{viewed.supported
? ` · ${parsed.files.filter((file) => viewed.isViewed(file.path)).length} viewed`
: ""}
</Text>
<Text className="text-2xs text-foreground-muted">
{" · "}
{filter.hideTestFiles
? `${filter.hiddenCount} test ${filter.hiddenCount === 1 ? "file" : "files"} hidden`
: "Test files included"}
Expand Down
87 changes: 75 additions & 12 deletions apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown";
import type { ReactNode } from "react";
import { View } from "react-native";
import { useProject } from "../../state/entities";
import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
import {
PullRequestMedia,
PullRequestMediaContext,
usePullRequestMediaUrl,
} from "./PullRequestMedia";
import { videoMimeType } from "@t3tools/shared/video";
import { createContext, useContext, useCallback, useMemo, useState } from "react";
import { githubMarkdownLinks } from "@t3tools/shared/githubMarkdownLinks";
Expand Down Expand Up @@ -25,7 +35,68 @@ import {
import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage";
import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText";

export const PullRequestMarkdownRepository = createContext<string | null>(null);
const RepositoryContext = createContext<string | null>(null);

export function PullRequestMarkdownRepository(props: {
readonly value: string | null;
readonly params: PullRequestParams;
readonly children: ReactNode;
}) {
const target = pullRequestTarget(props.params);
const project = useProject({
environmentId: target.environmentId,
projectId: target.input.projectId,
});
const cwd = project?.workspaceRoot;
const media = useMemo(
() => (cwd ? { environmentId: target.environmentId, cwd } : null),
[target.environmentId, cwd],
);
return (
<RepositoryContext value={props.value}>
<PullRequestMediaContext value={media}>{props.children}</PullRequestMediaContext>
</RepositoryContext>
);
}

function MarkdownImage(props: {
readonly href: string;
readonly alt: string | null;
readonly onPreview: (source: FilePreviewSource) => void;
}) {
const media = usePullRequestMediaUrl(props.href);
return (
<ThreadMarkdownImageView
uri={media.uri}
sourceKey={props.href}
unavailable={!/^https?:\/\//i.test(props.href)}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
alt={props.alt}
onPressPreview={props.onPreview}
/>
);
}

export function PullRequestMarkdown(props: {
readonly markdown: string;
readonly imageGroupMarkdown?: string;
}) {
const segments = useMemo(() => splitPullRequestBody(props.markdown), [props.markdown]);
return (
<View style={{ gap: 12 }}>
{segments.map((segment) =>
segment.kind === "markdown" ? (
<PullRequestMarkdownContent
key={segment.id}
markdown={segment.text}
imageGroupMarkdown={props.imageGroupMarkdown ?? props.markdown}
/>
) : (
<PullRequestMedia key={segment.id} url={segment.url} kind={segment.media} />
),
)}
</View>
);
}

function renderLink(
{ href, children }: CustomRendererProps,
Expand Down Expand Up @@ -61,11 +132,11 @@ function renderHtmlBlock(
) : null;
}

export function PullRequestMarkdown(props: {
function PullRequestMarkdownContent(props: {
readonly markdown: string;
readonly imageGroupMarkdown?: string;
}) {
const repositoryUrl = useContext(PullRequestMarkdownRepository);
const repositoryUrl = useContext(RepositoryContext);
const markdown = useMemo(
() => githubMarkdownLinks(props.markdown, repositoryUrl),
[props.markdown, repositoryUrl],
Expand All @@ -90,15 +161,7 @@ export function PullRequestMarkdown(props: {
);
const [preview, setPreview] = useState<FilePreviewSource | null>(null);
const renderImage = useCallback<MarkdownImageRenderer>(
(image) => (
<ThreadMarkdownImageView
uri={/^https?:\/\//i.test(image.href) ? image.href : null}
sourceKey={image.href}
unavailable={!/^https?:\/\//i.test(image.href)}
alt={image.alt}
onPressPreview={setPreview}
/>
),
(image) => <MarkdownImage href={image.href} alt={image.alt} onPreview={setPreview} />,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
[],
);
const styles = useMarkdownPreviewStyles(renderImage);
Expand Down
Loading
Loading