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
65 changes: 65 additions & 0 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,10 @@ import {
findProjectForChangeRequest,
matchesLinkedPullRequestUrl,
parseChangeRequestUrl,
pullRequestCandidateUrlFromReferenceAutolink,
useOpenChangeRequestLink,
} from "~/lib/openPullRequestLink";
import { useOpenLink } from "../browser/useOpenLink";
import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
import { isPreviewSupportedInRuntime } from "../previewStateStore";
import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links";
Expand All @@ -174,6 +176,7 @@ import {
BrowserPreviewUnavailableError,
} from "../browser/openFileInPreview";
import { resolveLinkTarget } from "../browser/browserLinkTarget";
import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";

interface ChatMarkdownProps {
text: string;
Expand Down Expand Up @@ -2121,6 +2124,20 @@ function ChatMarkdown({
event.clipboardData.setData("text/html", payload.html);
}, []);
const openChangeRequestLink = useOpenChangeRequestLink(threadRef);
const openDeferredMarkdownLink = useOpenLink(threadRef);
const openPullRequestInApp = useCallback(
(targetUrl: string) =>
openChangeRequestLink(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
{
metaKey: false,
ctrlKey: false,
preventDefault: () => undefined,
stopPropagation: () => undefined,
},
targetUrl,
),
[openChangeRequestLink],
);
// Subscribed rather than read at click time: the anchor has to decide
// synchronously whether to intercept its `_blank`, and a subscription is what
// makes a persisted "app" apply once settings hydrate after launch.
Expand Down Expand Up @@ -2442,6 +2459,37 @@ function ChatMarkdown({
? plainHastText(node)
: undefined;
const isPullRequestAutolink = pullRequestCopy !== undefined;
const pullRequestCandidateUrl =
pullRequestAutolink === "reference" && href
? pullRequestCandidateUrlFromReferenceAutolink(href)
: href;
const pullRequestCandidate = pullRequestCandidateUrl
? parseChangeRequestUrl(pullRequestCandidateUrl)
: null;
const pullRequestProject =
environmentId !== null &&
serverConfig?.environment.capabilities.pullRequests === true &&
pullRequestCandidate !== null
? findProjectForChangeRequest(
projects.filter((project) => project.environmentId === environmentId),
pullRequestCandidate,
)
: undefined;
const pullRequestPreviewTarget =
environmentId === null ||
pullRequestProject === undefined ||
pullRequestCandidate === null
? null
: {
environmentId,
input: {
projectId: pullRequestProject.id,
repository:
pullRequestProject.repositoryIdentity?.displayName ??
pullRequestCandidate.repository,
number: pullRequestCandidate.number,
},
};
const isSameDocumentLink = href?.startsWith("#") ?? false;
const onClick = props.onClick;
const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
Expand Down Expand Up @@ -2575,6 +2623,18 @@ function ChatMarkdown({
if (!faviconHost || !href) {
return link;
}
if (pullRequestPreviewTarget !== null) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
return (
<PullRequestLinkPreview
link={link}
originalUrl={href}
target={pullRequestPreviewTarget}
confirmBeforeOpen={pullRequestAutolink === "reference"}
onOpenPullRequest={openPullRequestInApp}
onOpenFallback={openDeferredMarkdownLink}
/>
);
}
return (
<Tooltip>
<TooltipTrigger render={link} />
Expand Down Expand Up @@ -2744,6 +2804,7 @@ function ChatMarkdown({
isStreaming,
linkTargetPreference,
markdownFileLinkMetaByHref,
environmentId,
onTaskListChange,
onUseArtifactTemplate,
onImageExpand,
Expand All @@ -2752,14 +2813,18 @@ function ChatMarkdown({
openFileInPanel,
openInPreferredEditor,
openChangeRequestLink,
openDeferredMarkdownLink,
openExternalLinkInPreview,
openMarkdownFileInPreview,
preferredEditorMenuLabel,
projects,
openPullRequestInApp,
resolveThreadPullRequest,
resolvedTheme,
revealMarkdownFileInFileManager,
revealInFileManagerLabel,
skills,
serverConfig,
text,
threadRef,
updateThreadPullRequestLink,
Expand Down
120 changes: 120 additions & 0 deletions apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";
import { cloneElement, useState, type ComponentPropsWithoutRef, type ReactElement } from "react";

import { formatRelativeTimeLabel } from "~/timestampFormat";
import { pullRequestEnvironment } from "~/state/pullRequests";
import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
import { useEnvironmentQuery } from "~/state/query";

import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card";
import { PullRequestActorAvatar, resolvePullRequestState } from "./pullRequestPresentation";

interface PullRequestLinkPreviewTarget {
readonly environmentId: EnvironmentId;
readonly input: PullRequestRef;
}

type PullRequestLinkElement = ReactElement<ComponentPropsWithoutRef<"a">>;

export function PullRequestLinkPreview({
link,
originalUrl,
target,
confirmBeforeOpen,
onOpenPullRequest,
onOpenFallback,
}: {
link: PullRequestLinkElement;
originalUrl: string;
target: PullRequestLinkPreviewTarget;
/** A bare GitHub `#123` can be either an issue or a pull request. */
confirmBeforeOpen: boolean;
onOpenPullRequest: (url: string) => boolean;
onOpenFallback: (url: string) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [resolvingClick, setResolvingClick] = useState(false);
const detailQuery = useEnvironmentQuery(
open
? pullRequestEnvironment.detail({
environmentId: target.environmentId,
input: target.input,
})
: null,
);
const readDetail = useAtomQueryRunner(pullRequestEnvironment.detail, {
reportFailure: false,
reportDefect: false,
});

const trigger = confirmBeforeOpen
? cloneElement(link, {
onClick: (event) => {
if (event.metaKey || event.ctrlKey) return;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
event.preventDefault();
event.stopPropagation();
if (resolvingClick) return;
setOpen(false);
setResolvingClick(true);
void readDetail({ environmentId: target.environmentId, input: target.input })
.then(async (result) => {
if (isAtomCommandInterrupted(result)) return;
if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return;
await onOpenFallback(originalUrl);
})
.catch((error: unknown) => {
console.error("[pull-request-link-preview] failed to open link", error);
})
.finally(() => setResolvingClick(false));
},
})
: link;
const detail = detailQuery.data;
const state =
detail === null
? null
: resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft });
const authorLabel =
detail?.author === null
? "ghost"
: detail?.author.name && detail.author.name !== detail.author.login
? `${detail.author.name} (@${detail.author.login})`
: (detail?.author.login ?? null);

return (
<PreviewCard open={open} onOpenChange={setOpen}>
<PreviewCardTrigger render={trigger} delay={350} closeDelay={120} />
<PreviewCardPopup className="w-80 max-w-[calc(100vw-2rem)] p-3">
{detail === null ? (
<p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere">
{detailQuery.isPending ? "Loading pull request details…" : originalUrl}
</p>
) : (
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
Comment thread
maria-rcks marked this conversation as resolved.
<span className="min-w-0 truncate">{detail.repository}</span>
<span className="shrink-0">#{detail.number}</span>
<span aria-hidden>·</span>
{state === null ? null : (
<span className="inline-flex shrink-0 items-center gap-1">
<state.Icon aria-hidden className={`size-3 ${state.toneClassName}`} />
{state.label}
</span>
)}
</div>
<p className="mt-1 text-sm font-medium leading-snug text-foreground text-pretty">
{detail.title}
</p>
<div className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<PullRequestActorAvatar actor={detail.author} className="size-4" />
<span className="min-w-0 truncate">{authorLabel}</span>
<span aria-hidden>·</span>
<span className="shrink-0">opened {formatRelativeTimeLabel(detail.createdAt)}</span>
</div>
</div>
)}
</PreviewCardPopup>
</PreviewCard>
);
}
44 changes: 44 additions & 0 deletions apps/web/src/components/ui/preview-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";

import { cn } from "~/lib/utils";

const PreviewCard = PreviewCardPrimitive.Root;

function PreviewCardTrigger(props: PreviewCardPrimitive.Trigger.Props) {
return <PreviewCardPrimitive.Trigger data-slot="preview-card-trigger" {...props} />;
}

function PreviewCardPopup({
className,
align = "start",
side = "top",
sideOffset = 6,
...props
}: PreviewCardPrimitive.Popup.Props & {
align?: PreviewCardPrimitive.Positioner.Props["align"];
side?: PreviewCardPrimitive.Positioner.Props["side"];
sideOffset?: PreviewCardPrimitive.Positioner.Props["sideOffset"];
}) {
return (
<PreviewCardPrimitive.Portal>
<PreviewCardPrimitive.Positioner
align={align}
className="z-[140] max-w-(--available-width)"
data-slot="preview-card-positioner"
side={side}
sideOffset={sideOffset}
>
<PreviewCardPrimitive.Popup
className={cn(
"origin-(--transform-origin) rounded-lg border bg-popover text-popover-foreground shadow-lg outline-none transition-[scale,opacity] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0",
className,
)}
data-slot="preview-card-popup"
{...props}
/>
</PreviewCardPrimitive.Positioner>
</PreviewCardPrimitive.Portal>
);
}

export { PreviewCard, PreviewCardPopup, PreviewCardTrigger };
24 changes: 24 additions & 0 deletions apps/web/src/lib/openPullRequestLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
matchesLinkedPullRequestUrl,
openPullRequestLink,
parseChangeRequestUrl,
pullRequestCandidateUrlFromReferenceAutolink,
PullRequestLinkOpenError,
shouldOpenPullRequestExternally,
} from "./openPullRequestLink";
Expand Down Expand Up @@ -130,6 +131,29 @@ describe("changeRequestRepositoryUrl", () => {
});
});

describe("pullRequestCandidateUrlFromReferenceAutolink", () => {
it("turns GitHub's shared issue route into a pull request candidate", () => {
expect(
pullRequestCandidateUrlFromReferenceAutolink(
"https://github.com/pingdotgg/t3code/issues/8600#issuecomment-1",
),
).toBe("https://github.com/pingdotgg/t3code/pull/8600#issuecomment-1");
});

it("does not reinterpret other issue hosts or malformed references", () => {
expect(
pullRequestCandidateUrlFromReferenceAutolink(
"https://gitlab.com/pingdotgg/t3code/-/issues/8600",
),
).toBeNull();
expect(
pullRequestCandidateUrlFromReferenceAutolink(
"https://github.com/pingdotgg/t3code/issues/not-a-number",
),
).toBeNull();
});
});

describe("matchesLinkedPullRequestUrl", () => {
const linkedPullRequest = {
projectId: ProjectId.make("project-1"),
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/lib/openPullRequestLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,30 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
return null;
}

/**
* The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare
* reference through `/issues/`, including pull requests, so this only builds a candidate: the
* caller must successfully read it as a pull request before treating it as one.
*/
export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null {
let url: URL;
try {
url = new URL(targetUrl);
} catch {
return null;
}
if (
(url.protocol !== "https:" && url.protocol !== "http:") ||
!isHostOf(url.hostname.toLowerCase(), "github.com", "github")
) {
return null;
}
const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname);
if (match?.[1] === undefined || match[2] === undefined) return null;
url.pathname = `/${match[1]}/pull/${match[2]}`;
return url.toString();
}

/** Match a stored PR without requiring its project to remain available. */
export function matchesLinkedPullRequestUrl(
linkedPullRequest: ThreadLinkedPullRequest,
Expand Down
Loading