Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
89 changes: 81 additions & 8 deletions apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,35 +15,44 @@ import {
import { videoMimeType } from "@t3tools/shared/video";
import { createContext, useContext, useMemo, useState } from "react";
import {
changeRequestLinkResolver,
changeRequestReferenceAutolinker,
resolveChangeRequestMediaUrl,
} from "@t3tools/client-runtime/pull-request-markdown";
import {
sourceControlClients,
type ReferenceAutolink,
} from "@t3tools/client-runtime/source-control-clients";
import { assetUrlStateFromResult } from "@t3tools/client-runtime/state/assets";
import { linkReferenceAutolinks } from "./pullRequestAutolinks";
import { assetEnvironment } from "../../state/assets";
import { usePreparedConnection } from "../../state/session";
import { useAtomQueryRunner } from "../../state/use-atom-query-runner";

import { useOpenPullRequest } from "../../native/useOpenPullRequest";
import { SelectableMarkdownText } from "../../native/SelectableMarkdownText";
import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview";
import { markdownImageSources } from "../../lib/markdownImageSources";
import { FilePreviewGallery, useFilePreviewGallery } from "../../components/ui/FilePreviewGallery";
import { pullRequestImageSource } from "./pullRequestMediaSource";
import { pullRequestImageSource, pullRequestMediaSource } from "./pullRequestMediaSource";
import {
FilePreviewModal,
type FilePreviewSource,
type FilePreviewItem,
} from "../../components/ui/FilePreviewModal";
import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage";
import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText";
import type {
MarkdownImageRenderer,
MarkdownImageSourceResolver,
} from "../../native/SelectableMarkdownText";
import type { SelectableMarkdownTextProps } from "../../native/SelectableMarkdownText.types";

const AutolinksContext = createContext<((text: string) => ReadonlyArray<ReferenceAutolink>) | null>(
null,
);
const LineBreaksContext = createContext(false);
const ChangeRequestUrlContext = createContext<string | null>(null);
const LinkResolverContext = createContext<((url: string) => string) | null>(null);

export function PullRequestMarkdownRepository(props: {
/** The change request the markdown belongs to, whose host decides what its text links. */
Expand All @@ -58,6 +67,7 @@ export function PullRequestMarkdownRepository(props: {
? null
: changeRequestReferenceAutolinker({ provider, url });
const lineBreaks = sourceControlClients.find(provider ?? "")?.hardLineBreaks ?? false;
const linkResolver = changeRequestLinkResolver(props.changeRequest);
const target = pullRequestTarget(props.params);
const project = useProject({
environmentId: target.environmentId,
Expand All @@ -71,13 +81,63 @@ export function PullRequestMarkdownRepository(props: {
<AutolinksContext value={autolinks}>
<LineBreaksContext value={lineBreaks}>
<ChangeRequestUrlContext value={url ?? null}>
<PullRequestMediaContext value={media}>{props.children}</PullRequestMediaContext>
<LinkResolverContext value={linkResolver}>
<PullRequestMediaContext value={media}>{props.children}</PullRequestMediaContext>
</LinkResolverContext>
</ChangeRequestUrlContext>
</LineBreaksContext>
</AutolinksContext>
);
}

/** A body's destinations, as its host resolves them; a guide's stay relative to the checkout. */
function useResolveLink() {
const linkResolver = useContext(LinkResolverContext);
return (href: string) => linkResolver?.(href) ?? href;
}

function useResolveMediaUrl() {
const resolveLink = useResolveLink();
const changeRequestUrl = useContext(ChangeRequestUrlContext);
return (href: string) => {
const url = resolveLink(href);
return changeRequestUrl ? resolveChangeRequestMediaUrl(url, changeRequestUrl) : url;
};
}

/**
* Enriched draws an image in a table, list or link itself, from this source, not `renderImage`.
* It keeps a resolved source for the life of the view, so the view is keyed to everything an
* answer depends on: the change request it resolves against, the media location and the origin.
*/
function usePullRequestImageSource(resolve: (url: string) => string): {
readonly resolveImageSource: MarkdownImageSourceResolver;
readonly sourceKey: string;
} {
const location = useContext(PullRequestMediaContext);
const connection = usePreparedConnection(location?.environmentId ?? null);
const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null;
const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false });
const resolveImageSource: MarkdownImageSourceResolver = async (image) => {
const url = resolve(image.href);
const source = pullRequestMediaSource(url, location);
if ("uri" in source) return /^https?:\/\//i.test(url) ? { uri: url } : null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const state = assetUrlStateFromResult(
await createUrl({
environmentId: source.environmentId,
input: { resource: source.resource },
}),
httpBaseUrl,
);
if (state._tag !== "Success") throw new Error("Pull request image is unavailable");
return { uri: state.url };
};
return {
resolveImageSource,
sourceKey: `${location?.environmentId ?? ""}\n${location?.cwd ?? ""}\n${httpBaseUrl ?? ""}`,
};
}

function MarkdownImage(props: {
readonly href: string;
readonly alt: string | null;
Expand Down Expand Up @@ -120,13 +180,13 @@ export function PullRequestMarkdown(props: {
}) => boolean;
}) {
const mediaContext = useContext(PullRequestMediaContext);
const changeRequestUrl = useContext(ChangeRequestUrlContext);
const resolveMediaUrl = useResolveMediaUrl();
const segments = useMemo(() => splitPullRequestBody(props.markdown), [props.markdown]);
const [undoneTicks, setUndoneTicks] = useState(0);
const gallery: FilePreviewItem[] = markdownImageSources(
props.imageGroupMarkdown ?? props.markdown,
)
.map((href) => (changeRequestUrl ? resolveChangeRequestMediaUrl(href, changeRequestUrl) : href))
.map(resolveMediaUrl)
.filter(
(uri) =>
/^https?:\/\//i.test(uri) &&
Expand All @@ -149,6 +209,15 @@ export function PullRequestMarkdown(props: {
);
}

/** The agent writes the guide about the checkout, so its relative links name workspace files. */
export function PullRequestGuideMarkdown(props: { readonly markdown: string }) {
return (
<LinkResolverContext value={null}>
<PullRequestMarkdown lineBreaks={false} markdown={props.markdown} />
</LinkResolverContext>
);
}

function PullRequestBodySegmentView(props: {
readonly segment: PullRequestBodySegment;
readonly gallery: FilePreviewItem[];
Expand Down Expand Up @@ -221,26 +290,30 @@ function PullRequestMarkdownContent(props: {
const autolinks = useContext(AutolinksContext);
const mediaContext = useContext(PullRequestMediaContext);
const changeRequestUrl = useContext(ChangeRequestUrlContext);
const resolveMediaUrl = (href: string) =>
changeRequestUrl ? resolveChangeRequestMediaUrl(href, changeRequestUrl) : href;
const resolveLink = useResolveLink();
const resolveMediaUrl = useResolveMediaUrl();
const markdown = linkReferenceAutolinks(props.markdown, autolinks);
const openPullRequest = useOpenPullRequest();
const openLink = (href: string) => {
void openPullRequest(href);
void openPullRequest(resolveLink(href));
};
const [preview, setPreview] = useState<FilePreviewSource | null>(null);
const renderImage: MarkdownImageRenderer = (image) => (
<MarkdownImage href={resolveMediaUrl(image.href)} alt={image.alt} onPreview={setPreview} />
);
const { resolveImageSource, sourceKey } = usePullRequestImageSource(resolveMediaUrl);
const nativeKey = `${changeRequestUrl ?? ""}\n${sourceKey}`;
const styles = useMarkdownPreviewStyles();
return (
<FilePreviewGallery value={props.gallery}>
<SelectableMarkdownText
key={nativeKey}
markdown={markdown}
preserveSoftBreaks={props.lineBreaks ?? hostLineBreaks}
textStyle={styles.nativeTextStyle}
onLinkPress={openLink}
renderImage={renderImage}
resolveImageSource={resolveImageSource}
onImagePress={(href) =>
setPreview(pullRequestImageSource(resolveMediaUrl(href), mediaContext))
}
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { stopFilePaths } from "@t3tools/client-runtime/review-guide-tour";
import { Pressable, View } from "react-native";
import { AppText as Text } from "../../../components/ui/AppText";
import { SymbolView } from "../../../components/ui/AppSymbol";
import { PullRequestMarkdown } from "../PullRequestMarkdown";
import { PullRequestGuideMarkdown } from "../PullRequestMarkdown";
import { Menu } from "../../../components/ui/Menu";

import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider";
Expand Down Expand Up @@ -59,7 +59,7 @@ export function GuideOutline({
</View>
{guide.stops[0]?.summary ? (
<View className="mb-6">
<PullRequestMarkdown lineBreaks={false} markdown={guide.stops[0].summary} />
<PullRequestGuideMarkdown markdown={guide.stops[0].summary} />
</View>
) : null}
{guide.stops.map((stop, position) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { AppText as Text } from "../../../components/ui/AppText";
import { ScreenScrollView } from "../../../components/ui/ScreenScrollView";
import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider";
import { PullRequestCardGroup, PullRequestCardRow } from "../PullRequestControls";
import { PullRequestMarkdown, PullRequestMarkdownRepository } from "../PullRequestMarkdown";
import { PullRequestGuideMarkdown, PullRequestMarkdownRepository } from "../PullRequestMarkdown";
import { usePullRequestDiff, type PullRequestParams } from "../usePullRequestDiff";
import { ReviewGuideExcerpt } from "./ReviewGuideExcerpt";
import { reviewGuideState } from "./reviewGuideState";
Expand Down Expand Up @@ -163,7 +163,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideSto
<>
<GuideNotice state={state} />
<Text className="text-2xl leading-8 font-t3-semibold">{stop.title}</Text>
<PullRequestMarkdown lineBreaks={false} markdown={stop.summary} />
<PullRequestGuideMarkdown markdown={stop.summary} />
{stop.attention ? (
<View className="gap-3 rounded-xl border border-border bg-grouped-card p-4">
<View className="flex-row items-center gap-2">
Expand All @@ -172,7 +172,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideSto
{attentionLabels[stop.attention.kind]}
</Text>
</View>
<PullRequestMarkdown lineBreaks={false} markdown={stop.attention.note} />
<PullRequestGuideMarkdown markdown={stop.attention.note} />
</View>
) : null}
{stale ? (
Expand Down Expand Up @@ -212,7 +212,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideSto
<Text className="text-xs text-foreground-muted">
{note.path.split("/").at(-1)}:{note.line}
</Text>
<PullRequestMarkdown lineBreaks={false} markdown={note.body} />
<PullRequestGuideMarkdown markdown={note.body} />
</View>
</View>
))}
Expand Down Expand Up @@ -241,7 +241,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideSto
<View className="gap-3">
<Text className="font-t3-semibold">Questions to consider</Text>
{stop.questions.map((question) => (
<PullRequestMarkdown key={question} lineBreaks={false} markdown={question} />
<PullRequestGuideMarkdown key={question} markdown={question} />
))}
</View>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { buildReviewParsedDiff } from "../../review/reviewModel";
import { useNativeReviewDiffBridge } from "../../review/useNativeReviewDiffBridge";
import { useAppearanceCodeSurface } from "../../settings/appearance/useAppearanceCodeSurface";
import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider";
import { PullRequestMarkdown } from "../PullRequestMarkdown";
import { PullRequestGuideMarkdown } from "../PullRequestMarkdown";

const EMPTY_IDS: readonly string[] = [];

Expand Down Expand Up @@ -55,9 +55,7 @@ export function ReviewGuideExcerpt({
const [NativeDiff] = useState(() => resolveNativeReviewDiffView());
return (
<View className="gap-3">
{excerpt.caption.trim() ? (
<PullRequestMarkdown lineBreaks={false} markdown={excerpt.caption} />
) : null}
{excerpt.caption.trim() ? <PullRequestGuideMarkdown markdown={excerpt.caption} /> : null}
<View className="overflow-hidden rounded-lg border border-border-subtle">
<Pressable
accessibilityRole="button"
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/components/ChatMarkdown.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ vi.mock(import("../hooks/useSettings"), async (original) => {

import ChatMarkdown from "./ChatMarkdown";
import { AudioPreview } from "./files/AudioPreview";
import { pullRequestRehypePlugins } from "./pullRequest/pullRequestMarkdown.logic";

it("keeps compact audio playback loaded across signed URL renewals", async () => {
const original =
Expand Down Expand Up @@ -275,6 +276,39 @@ it("reuses the parse of a remounted message that has stable extra plugins", asyn
expect(parsed).toHaveBeenCalledTimes(1);
});

it("resolves destinations in parsed html and reference links, but not code", async () => {
setup();
const extraRehypePlugins = pullRequestRehypePlugins({
provider: "github",
url: "https://github.com/team/repo/pull/8",
});
await using screen = await renderText(
[
'<img alt="before > after" src="/team/repo/blob/abc/a.png">',
"",
`<img alt="b" title="src='example'" src="../blob/abc/b.png">`,
"",
'<a href="../blob/abc/docs.md">docs</a> [ref][r] `![c](../blob/abc/c.png)`',
"",
"[r]: ../blob/abc/ref.md",
].join("\n"),
{ extraRehypePlugins },
);
await expect
.element(screen.getByAltText("before > after"))
.toHaveAttribute("src", "https://github.com/team/repo/blob/abc/a.png");
await expect
.element(screen.getByAltText("b"))
.toHaveAttribute("src", "https://github.com/team/repo/blob/abc/b.png");
await expect
.element(screen.getByRole("link", { name: "docs" }))
.toHaveAttribute("href", "https://github.com/team/repo/blob/abc/docs.md");
await expect
.element(screen.getByRole("link", { name: "ref" }))
.toHaveAttribute("href", "https://github.com/team/repo/blob/abc/ref.md");
await expect.element(screen.getByText("![c](../blob/abc/c.png)", { exact: true })).toBeVisible();
});

describe("ChatMarkdown favicon privacy", () => {
it("suppresses private link images while preserving public links across updates", async () => {
setup();
Expand Down
11 changes: 8 additions & 3 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ interface ChatMarkdownProps {
/** The change request's repository, when it can differ from the checkout at `cwd`. */
sourceControlMediaRepository?: string | undefined;
headingLevelOffset?: number | undefined;
/** Run after raw HTML is parsed and sanitized; keep the list stable to share the parse cache. */
extraRehypePlugins?: PluggableList;
}

export interface ChatMarkdownContextReference {
Expand All @@ -261,6 +263,7 @@ export interface ChatMarkdownContextReference {

const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<ServerProviderSkill> = [];
const EMPTY_REMARK_PLUGINS: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [];
const EMPTY_REHYPE_PLUGINS: PluggableList = [];

const ARTIFACT_TEMPLATE_ICON_BY_KIND = {
document: FileTextIcon,
Expand Down Expand Up @@ -1746,6 +1749,7 @@ function ChatMarkdown({
lineBreaks = false,
parseRawHtml = true,
extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
extraRehypePlugins = EMPTY_REHYPE_PLUGINS,
...props
}: ChatMarkdownProps) {
const { componentState, handleCopy, markdownRef, localMediaPreview, closeLocalMediaPreview } =
Expand Down Expand Up @@ -1803,11 +1807,12 @@ function ChatMarkdown({
key={index}
text={source}
remarkPlugins={remarkPlugins}
rehypePlugins={
rehypePlugins={withExtraPlugins(
parseRawHtml
? CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS
: CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS
}
: CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS,
extraRehypePlugins,
)}
shared={!uncachedPlugins}
// Every block before the last is finished, so its parse can be reused.
cache={!props.isStreaming || index < blocks.length - 1}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab";
import { openOnHostLabel } from "./pullRequestLinkContextMenu";
import { PullRequestMarkdownContext } from "./PullRequestMarkdown";
import { pullRequestMarkdownPlugins } from "./pullRequestMarkdown.logic";
import { pullRequestMarkdownPlugins, pullRequestRehypePlugins } from "./pullRequestMarkdown.logic";
import { PullRequestComposer } from "./PullRequestComposer";
import { PullRequestSummaryTab } from "./PullRequestSummaryTab";
import { PullRequestTimelineTab } from "./PullRequestTimelineTab";
Expand Down Expand Up @@ -244,6 +244,7 @@ function pullRequestMarkdownContext(
) {
return {
remarkPlugins: pullRequestMarkdownPlugins(detail),
rehypePlugins: pullRequestRehypePlugins(detail),
lineBreaks: sourceControlClients.find(detail?.provider ?? "")?.hardLineBreaks ?? false,
threadRef,
repository: detail?.repository ?? null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ async function renderBody(
const { repository = null, text = "first line\nsecond line", ...props } = options;
const context = {
remarkPlugins: [],
rehypePlugins: [],
lineBreaks: sourceControlClients.get(host).hardLineBreaks ?? false,
threadRef: null,
repository,
Expand Down
Loading
Loading