Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ vi.mock("../editorPreferences", () => ({
vi.mock("~/lib/openPullRequestLink", () => ({
findProjectOnChangeRequestHost: () => undefined,
parseChangeRequestUrl: () => null,
resolvePullRequestPreviewTarget: () => null,
useOpenChangeRequestLink: () => vi.fn(),
}));

Expand Down
35 changes: 8 additions & 27 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li
import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
import * as Cause from "effect/Cause";
import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
import { AsyncResult } from "effect/unstable/reactivity";
import React, {
Children,
Expand Down Expand Up @@ -178,9 +177,9 @@ import {
WORKSPACE_BASENAME_LOOKUP_LIMIT,
} from "../workspaceBasenameLookup";
import {
findProjectForChangeRequest,
parseChangeRequestUrl,
pullRequestCandidateUrlFromReferenceAutolink,
resolvePullRequestPreviewTarget,
useOpenChangeRequestLink,
} from "~/lib/openPullRequestLink";
import { useOpenLink } from "../browser/useOpenLink";
Expand Down Expand Up @@ -2886,32 +2885,14 @@ const CHAT_MARKDOWN_COMPONENTS = {
const confirmBeforeOpen = pullRequestAutolink === "reference";
const pullRequestCandidateUrl =
confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href;
const pullRequestCandidate = pullRequestCandidateUrl
? parseChangeRequestUrl(pullRequestCandidateUrl)
const pullRequestPreviewTarget = pullRequestCandidateUrl
? resolvePullRequestPreviewTarget({
environmentId,
projects,
pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true,
url: 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,
host: pullRequestCandidate.authority ?? pullRequestCandidate.host,
repository:
sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ??
pullRequestCandidate.repository,
number: pullRequestCandidate.number,
},
};
const isSameDocumentLink = href?.startsWith("#") ?? false;
const onClick = props.onClick;
const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ vi.mock("../editorPreferences", () => ({
vi.mock("~/lib/openPullRequestLink", () => ({
findProjectOnChangeRequestHost: () => undefined,
parseChangeRequestUrl: () => null,
resolvePullRequestPreviewTarget: () => null,
useOpenChangeRequestLink: () => vi.fn(),
}));

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1626,6 +1626,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const previewFile = composerFiles.find((file) => file.id === previewFileId);
const composerContextActions = useMemo(
() => ({
environmentId,
expandImage: (imageId: string) => {
const preview = buildExpandedImagePreview(composerImages, imageId);
if (preview) onExpandImage(preview);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3508,12 +3508,13 @@ function UserMessagePullRequestContextChip(props: {
copyMarkdown: string;
toneClassName: string;
}) {
const { openPullRequest } = use(TimelineRowCtx);
const { activeThreadEnvironmentId, openPullRequest } = use(TimelineRowCtx);
const metadata = props.record.pullRequest;
if (metadata === undefined) return null;
return (
<PullRequestChip
metadata={metadata}
environmentId={activeThreadEnvironmentId}
label={reviewCommentContextLabel(props.record)}
kindLabel={pullRequestContextKindLabel(props.record)}
className={cn(CHAT_INLINE_CHIP_CLASS_NAME, props.toneClassName)}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/composerContextPresentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"
import { videoMimeType } from "@t3tools/shared/video";
import { MessageCircleIcon, MousePointerClickIcon } from "lucide-react";
import { createContext, type MouseEvent, type ReactElement, type ReactNode, use } from "react";
import type { EnvironmentId } from "@t3tools/contracts";

import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore";
import { composerFileNeedsReattach } from "~/composerDraftStore";
Expand Down Expand Up @@ -66,6 +67,7 @@ export type ComposerDraftContextRecord =

/** What a chip can do beyond showing itself; the composer supplies the handlers. */
export interface ComposerContextActions {
environmentId: EnvironmentId | null;
expandImage: (imageId: string) => void;
expandVideo: (fileId: string) => void;
openFile: (fileId: string) => void;
Expand All @@ -74,6 +76,7 @@ export interface ComposerContextActions {
}

export const ComposerContextActionsContext = createContext<ComposerContextActions>({
environmentId: null,
expandImage: () => {},
expandVideo: () => {},
openFile: () => {},
Expand Down Expand Up @@ -247,6 +250,7 @@ function PullRequestContextChip(props: { record: ReviewCommentContext; toneClass
return (
<PullRequestChip
metadata={metadata}
environmentId={actions.environmentId}
label={reviewCommentContextLabel(props.record)}
kindLabel={pullRequestContextKindLabel(props.record)}
className={cn(COMPOSER_INLINE_CHIP_CLASS_NAME, props.toneClassName)}
Expand Down
58 changes: 36 additions & 22 deletions apps/web/src/components/contextChipParts.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PullRequestContextMetadata } from "@t3tools/contracts";
import type { EnvironmentId, PullRequestContextMetadata } from "@t3tools/contracts";
import { CircleDashedIcon, FilmIcon, ImageIcon } from "lucide-react";
import {
useState,
Expand All @@ -23,6 +23,8 @@ import { PullRequestContextDetails } from "./PullRequestContextDetails";
import { Button } from "./ui/button";
import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
import { usePullRequestPreviewTarget } from "~/lib/openPullRequestLink";

/** Shared visual slots; each surface keeps ownership of payload lookup and actions. */
export function ContextChipShell({
Expand Down Expand Up @@ -112,36 +114,48 @@ export function ContextChipPopover(props: {

export function PullRequestChip(props: {
metadata: PullRequestContextMetadata;
environmentId: EnvironmentId | null;
label: string;
kindLabel: string;
className: string;
labelClassName: string;
copyMarkdown?: string;
onOpen: (event: MouseEvent<HTMLElement>, url: string) => void;
}) {
const previewTarget = usePullRequestPreviewTarget(props.environmentId, props.metadata.url);
const button = (
<Button
variant="chip"
className={cn(
props.className,
CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME,
CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
"cursor-pointer",
)}
aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`}
data-markdown-copy={props.copyMarkdown}
onClick={(event) => props.onOpen(event, props.metadata.url)}
>
<PullRequestGlyph.pullRequest
className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")}
/>
<span className={props.labelClassName}>{props.label}</span>
</Button>
);
if (previewTarget !== null) {
return (
<PullRequestLinkPreview
link={button}
originalUrl={props.metadata.url}
target={previewTarget}
confirmBeforeOpen={false}
fallback={<PullRequestContextDetails metadata={props.metadata} />}
/>
);
}
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="chip"
className={cn(
props.className,
CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME,
CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
"cursor-pointer",
)}
aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`}
data-markdown-copy={props.copyMarkdown}
onClick={(event) => props.onOpen(event, props.metadata.url)}
>
<PullRequestGlyph.pullRequest
className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")}
/>
<span className={props.labelClassName}>{props.label}</span>
</Button>
}
/>
<TooltipTrigger render={button} />
<TooltipPopup side="top">
<PullRequestContextDetails metadata={props.metadata} />
</TooltipPopup>
Expand Down
74 changes: 44 additions & 30 deletions apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
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 {
cloneElement,
useState,
type ComponentPropsWithoutRef,
type MouseEvent,
type ReactElement,
type ReactNode,
} from "react";

import { formatRelativeTimeLabel } from "~/timestampFormat";
import { pullRequestEnvironment } from "~/state/pullRequests";
Expand All @@ -15,7 +22,9 @@ interface PullRequestLinkPreviewTarget {
readonly input: PullRequestRef;
}

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

export function PullRequestLinkPreview({
link,
Expand All @@ -24,13 +33,15 @@ export function PullRequestLinkPreview({
confirmBeforeOpen,
onOpenPullRequest,
onOpenFallback,
fallback,
}: {
link: PullRequestLinkElement;
originalUrl: string;
target: PullRequestLinkPreviewTarget;
confirmBeforeOpen: boolean;
onOpenPullRequest: (url: string) => boolean;
onOpenFallback: (url: string) => Promise<void>;
confirmBeforeOpen?: boolean;
onOpenPullRequest?: (url: string) => boolean;
onOpenFallback?: (url: string) => Promise<void>;
fallback?: ReactNode;
}) {
const [open, setOpen] = useState(false);
const [resolvingClick, setResolvingClick] = useState(false);
Expand All @@ -46,28 +57,29 @@ export function PullRequestLinkPreview({
reportFailure: false,
reportDefect: false,
});
const trigger = confirmBeforeOpen
? cloneElement(link, {
onClick: (event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
event.stopPropagation();
if (resolvingClick) return;
setOpen(false);
setResolvingClick(true);
void readPreview(target)
.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 trigger =
confirmBeforeOpen === true
? cloneElement(link, {
onClick: (event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
event.stopPropagation();
if (resolvingClick) return;
setOpen(false);
setResolvingClick(true);
void readPreview(target)
.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
Expand All @@ -86,9 +98,11 @@ export function PullRequestLinkPreview({
{detail !== null || detailQuery.error !== null ? (
<PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3">
{detail === null ? (
<p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere">
{originalUrl}
</p>
(fallback ?? (
<p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere">
{originalUrl}
</p>
))
) : (
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
Expand Down
51 changes: 49 additions & 2 deletions apps/web/src/lib/openPullRequestLink.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { useNavigate } from "@tanstack/react-router";
import { type MouseEvent, useCallback } from "react";
import { type MouseEvent, useCallback, useMemo } from "react";

import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";
import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
Expand All @@ -15,6 +16,7 @@ import { useRightPanelStore } from "../rightPanelStore";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";

import { useProjects, useServerConfigs } from "../state/entities";
import { serverEnvironment } from "../state/server";
import { usePrimaryEnvironmentId } from "../state/environments";

export {
Expand Down Expand Up @@ -93,6 +95,51 @@ export function findProjectForChangeRequest(
});
}

export function resolvePullRequestPreviewTarget({
environmentId,
projects,
pullRequestsEnabled,
url,
}: {
environmentId: EnvironmentId | null;
projects: ReadonlyArray<EnvironmentProject>;
pullRequestsEnabled: boolean;
url: string;
}): { environmentId: EnvironmentId; input: PullRequestRef } | null {
if (!pullRequestsEnabled || environmentId === null) return null;
const parsed = parseChangeRequestUrl(url);
if (parsed === null) return null;
const project = findProjectForChangeRequest(
projects.filter((candidate) => candidate.environmentId === environmentId),
parsed,
);
if (project === undefined) return null;
return {
environmentId,
input: {
projectId: project.id,
host: parsed.authority ?? parsed.host,
repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository,
number: parsed.number,
},
};
}

export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, url: string) {
const projects = useProjects();
const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
return useMemo(
() =>
resolvePullRequestPreviewTarget({
environmentId,
projects,
pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true,
url,
}),
[environmentId, projects, serverConfig, url],
);
}

/**
* Any project checked out from the link's host. Thread links are host-level, so a pull request
* from a repository nobody has checked out is still linkable as long as one project on that
Expand Down
Loading