| author | |
| committer | |
| log | 93349247008517663aa16a3ed145d5b0f3f044ff |
| tree | a14e016d045d85c23eb78ed3d896e2fa3b69864e |
| parent | adcd90858c3bfece59095465d2f49860ba3dcea9 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 146 insertions(+), 82 deletions(-)
apps/web/src/components/ChatMarkdown.test.tsx+1| ... | @@ -55,6 +55,7 @@ vi.mock("../editorPreferences", () => ({ | ... | @@ -55,6 +55,7 @@ vi.mock("../editorPreferences", () => ({ |
| 55 | vi.mock("~/lib/openPullRequestLink", () => ({ | 55 | vi.mock("~/lib/openPullRequestLink", () => ({ |
| 56 | findProjectOnChangeRequestHost: () => undefined, | 56 | findProjectOnChangeRequestHost: () => undefined, |
| 57 | parseChangeRequestUrl: () => null, | 57 | parseChangeRequestUrl: () => null, |
| 58 | resolvePullRequestPreviewTarget: () => null, | ||
| 58 | useOpenChangeRequestLink: () => vi.fn(), | 59 | useOpenChangeRequestLink: () => vi.fn(), |
| 59 | })); | 60 | })); |
| 60 | 61 |
apps/web/src/components/ChatMarkdown.tsx+8-27| ... | @@ -53,7 +53,6 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li | ... | @@ -53,7 +53,6 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li |
| 53 | import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; | 53 | import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; |
| 54 | import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | 54 | import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; |
| 55 | import * as Cause from "effect/Cause"; | 55 | import * as Cause from "effect/Cause"; |
| 56 | import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; | ||
| 57 | import { AsyncResult } from "effect/unstable/reactivity"; | 56 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 58 | import React, { | 57 | import React, { |
| 59 | Children, | 58 | Children, |
| ... | @@ -178,9 +177,9 @@ import { | ... | @@ -178,9 +177,9 @@ import { |
| 178 | WORKSPACE_BASENAME_LOOKUP_LIMIT, | 177 | WORKSPACE_BASENAME_LOOKUP_LIMIT, |
| 179 | } from "../workspaceBasenameLookup"; | 178 | } from "../workspaceBasenameLookup"; |
| 180 | import { | 179 | import { |
| 181 | findProjectForChangeRequest, | ||
| 182 | parseChangeRequestUrl, | 180 | parseChangeRequestUrl, |
| 183 | pullRequestCandidateUrlFromReferenceAutolink, | 181 | pullRequestCandidateUrlFromReferenceAutolink, |
| 182 | resolvePullRequestPreviewTarget, | ||
| 184 | useOpenChangeRequestLink, | 183 | useOpenChangeRequestLink, |
| 185 | } from "~/lib/openPullRequestLink"; | 184 | } from "~/lib/openPullRequestLink"; |
| 186 | import { useOpenLink } from "../browser/useOpenLink"; | 185 | import { useOpenLink } from "../browser/useOpenLink"; |
| ... | @@ -2889,32 +2888,14 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -2889,32 +2888,14 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2889 | const confirmBeforeOpen = pullRequestAutolink === "reference"; | 2888 | const confirmBeforeOpen = pullRequestAutolink === "reference"; |
| 2890 | const pullRequestCandidateUrl = | 2889 | const pullRequestCandidateUrl = |
| 2891 | confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href; | 2890 | confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href; |
| 2892 | const pullRequestCandidate = pullRequestCandidateUrl | 2891 | const pullRequestPreviewTarget = pullRequestCandidateUrl |
| 2893 | ? parseChangeRequestUrl(pullRequestCandidateUrl) | 2892 | ? resolvePullRequestPreviewTarget({ |
| 2893 | environmentId, | ||
| 2894 | projects, | ||
| 2895 | pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true, | ||
| 2896 | url: pullRequestCandidateUrl, | ||
| 2897 | }) | ||
| 2894 | : null; | 2898 | : null; |
| 2895 | const pullRequestProject = | ||
| 2896 | environmentId !== null && | ||
| 2897 | serverConfig?.environment.capabilities.pullRequests === true && | ||
| 2898 | pullRequestCandidate !== null | ||
| 2899 | ? findProjectForChangeRequest( | ||
| 2900 | projects.filter((project) => project.environmentId === environmentId), | ||
| 2901 | pullRequestCandidate, | ||
| 2902 | ) | ||
| 2903 | : undefined; | ||
| 2904 | const pullRequestPreviewTarget = | ||
| 2905 | environmentId === null || pullRequestProject === undefined || pullRequestCandidate === null | ||
| 2906 | ? null | ||
| 2907 | : { | ||
| 2908 | environmentId, | ||
| 2909 | input: { | ||
| 2910 | projectId: pullRequestProject.id, | ||
| 2911 | host: pullRequestCandidate.authority ?? pullRequestCandidate.host, | ||
| 2912 | repository: | ||
| 2913 | sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ?? | ||
| 2914 | pullRequestCandidate.repository, | ||
| 2915 | number: pullRequestCandidate.number, | ||
| 2916 | }, | ||
| 2917 | }; | ||
| 2918 | const isSameDocumentLink = href?.startsWith("#") ?? false; | 2899 | const isSameDocumentLink = href?.startsWith("#") ?? false; |
| 2919 | const onClick = props.onClick; | 2900 | const onClick = props.onClick; |
| 2920 | const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); | 2901 | const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); |
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+1| ... | @@ -44,6 +44,7 @@ vi.mock("../editorPreferences", () => ({ | ... | @@ -44,6 +44,7 @@ vi.mock("../editorPreferences", () => ({ |
| 44 | vi.mock("~/lib/openPullRequestLink", () => ({ | 44 | vi.mock("~/lib/openPullRequestLink", () => ({ |
| 45 | findProjectOnChangeRequestHost: () => undefined, | 45 | findProjectOnChangeRequestHost: () => undefined, |
| 46 | parseChangeRequestUrl: () => null, | 46 | parseChangeRequestUrl: () => null, |
| 47 | resolvePullRequestPreviewTarget: () => null, | ||
| 47 | useOpenChangeRequestLink: () => vi.fn(), | 48 | useOpenChangeRequestLink: () => vi.fn(), |
| 48 | })); | 49 | })); |
| 49 | 50 |
apps/web/src/components/chat/ChatComposer.tsx+1| ... | @@ -1626,6 +1626,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1626,6 +1626,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1626 | const previewFile = composerFiles.find((file) => file.id === previewFileId); | 1626 | const previewFile = composerFiles.find((file) => file.id === previewFileId); |
| 1627 | const composerContextActions = useMemo( | 1627 | const composerContextActions = useMemo( |
| 1628 | () => ({ | 1628 | () => ({ |
| 1629 | environmentId, | ||
| 1629 | expandImage: (imageId: string) => { | 1630 | expandImage: (imageId: string) => { |
| 1630 | const preview = buildExpandedImagePreview(composerImages, imageId); | 1631 | const preview = buildExpandedImagePreview(composerImages, imageId); |
| 1631 | if (preview) onExpandImage(preview); | 1632 | if (preview) onExpandImage(preview); |
apps/web/src/components/chat/MessagesTimeline.tsx+2-1| ... | @@ -3508,12 +3508,13 @@ function UserMessagePullRequestContextChip(props: { | ... | @@ -3508,12 +3508,13 @@ function UserMessagePullRequestContextChip(props: { |
| 3508 | copyMarkdown: string; | 3508 | copyMarkdown: string; |
| 3509 | toneClassName: string; | 3509 | toneClassName: string; |
| 3510 | }) { | 3510 | }) { |
| 3511 | const { openPullRequest } = use(TimelineRowCtx); | 3511 | const { activeThreadEnvironmentId, openPullRequest } = use(TimelineRowCtx); |
| 3512 | const metadata = props.record.pullRequest; | 3512 | const metadata = props.record.pullRequest; |
| 3513 | if (metadata === undefined) return null; | 3513 | if (metadata === undefined) return null; |
| 3514 | return ( | 3514 | return ( |
| 3515 | <PullRequestChip | 3515 | <PullRequestChip |
| 3516 | metadata={metadata} | 3516 | metadata={metadata} |
| 3517 | environmentId={activeThreadEnvironmentId} | ||
| 3517 | label={reviewCommentContextLabel(props.record)} | 3518 | label={reviewCommentContextLabel(props.record)} |
| 3518 | kindLabel={pullRequestContextKindLabel(props.record)} | 3519 | kindLabel={pullRequestContextKindLabel(props.record)} |
| 3519 | className={cn(CHAT_INLINE_CHIP_CLASS_NAME, props.toneClassName)} | 3520 | className={cn(CHAT_INLINE_CHIP_CLASS_NAME, props.toneClassName)} |
apps/web/src/components/composerContextPresentation.tsx+4| ... | @@ -5,6 +5,7 @@ import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments" | ... | @@ -5,6 +5,7 @@ import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments" |
| 5 | import { videoMimeType } from "@t3tools/shared/video"; | 5 | import { videoMimeType } from "@t3tools/shared/video"; |
| 6 | import { MessageCircleIcon, MousePointerClickIcon } from "lucide-react"; | 6 | import { MessageCircleIcon, MousePointerClickIcon } from "lucide-react"; |
| 7 | import { createContext, type MouseEvent, type ReactElement, type ReactNode, use } from "react"; | 7 | import { createContext, type MouseEvent, type ReactElement, type ReactNode, use } from "react"; |
| 8 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 8 | 9 | ||
| 9 | import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore"; | 10 | import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore"; |
| 10 | import { composerFileNeedsReattach } from "~/composerDraftStore"; | 11 | import { composerFileNeedsReattach } from "~/composerDraftStore"; |
| ... | @@ -66,6 +67,7 @@ export type ComposerDraftContextRecord = | ... | @@ -66,6 +67,7 @@ export type ComposerDraftContextRecord = |
| 66 | 67 | ||
| 67 | /** What a chip can do beyond showing itself; the composer supplies the handlers. */ | 68 | /** What a chip can do beyond showing itself; the composer supplies the handlers. */ |
| 68 | export interface ComposerContextActions { | 69 | export interface ComposerContextActions { |
| 70 | environmentId: EnvironmentId | null; | ||
| 69 | expandImage: (imageId: string) => void; | 71 | expandImage: (imageId: string) => void; |
| 70 | expandVideo: (fileId: string) => void; | 72 | expandVideo: (fileId: string) => void; |
| 71 | openFile: (fileId: string) => void; | 73 | openFile: (fileId: string) => void; |
| ... | @@ -74,6 +76,7 @@ export interface ComposerContextActions { | ... | @@ -74,6 +76,7 @@ export interface ComposerContextActions { |
| 74 | } | 76 | } |
| 75 | 77 | ||
| 76 | export const ComposerContextActionsContext = createContext<ComposerContextActions>({ | 78 | export const ComposerContextActionsContext = createContext<ComposerContextActions>({ |
| 79 | environmentId: null, | ||
| 77 | expandImage: () => {}, | 80 | expandImage: () => {}, |
| 78 | expandVideo: () => {}, | 81 | expandVideo: () => {}, |
| 79 | openFile: () => {}, | 82 | openFile: () => {}, |
| ... | @@ -247,6 +250,7 @@ function PullRequestContextChip(props: { record: ReviewCommentContext; toneClass | ... | @@ -247,6 +250,7 @@ function PullRequestContextChip(props: { record: ReviewCommentContext; toneClass |
| 247 | return ( | 250 | return ( |
| 248 | <PullRequestChip | 251 | <PullRequestChip |
| 249 | metadata={metadata} | 252 | metadata={metadata} |
| 253 | environmentId={actions.environmentId} | ||
| 250 | label={reviewCommentContextLabel(props.record)} | 254 | label={reviewCommentContextLabel(props.record)} |
| 251 | kindLabel={pullRequestContextKindLabel(props.record)} | 255 | kindLabel={pullRequestContextKindLabel(props.record)} |
| 252 | className={cn(COMPOSER_INLINE_CHIP_CLASS_NAME, props.toneClassName)} | 256 | className={cn(COMPOSER_INLINE_CHIP_CLASS_NAME, props.toneClassName)} |
apps/web/src/components/contextChipParts.tsx+36-22| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { PullRequestContextMetadata } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, PullRequestContextMetadata } from "@t3tools/contracts"; |
| 2 | import { CircleDashedIcon, FilmIcon, ImageIcon } from "lucide-react"; | 2 | import { CircleDashedIcon, FilmIcon, ImageIcon } from "lucide-react"; |
| 3 | import { | 3 | import { |
| 4 | useState, | 4 | useState, |
| ... | @@ -23,6 +23,8 @@ import { PullRequestContextDetails } from "./PullRequestContextDetails"; | ... | @@ -23,6 +23,8 @@ import { PullRequestContextDetails } from "./PullRequestContextDetails"; |
| 23 | import { Button } from "./ui/button"; | 23 | import { Button } from "./ui/button"; |
| 24 | import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover"; | 24 | import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover"; |
| 25 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 25 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 26 | import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; | ||
| 27 | import { usePullRequestPreviewTarget } from "~/lib/openPullRequestLink"; | ||
| 26 | 28 | ||
| 27 | /** Shared visual slots; each surface keeps ownership of payload lookup and actions. */ | 29 | /** Shared visual slots; each surface keeps ownership of payload lookup and actions. */ |
| 28 | export function ContextChipShell({ | 30 | export function ContextChipShell({ |
| ... | @@ -112,6 +114,7 @@ export function ContextChipPopover(props: { | ... | @@ -112,6 +114,7 @@ export function ContextChipPopover(props: { |
| 112 | 114 | ||
| 113 | export function PullRequestChip(props: { | 115 | export function PullRequestChip(props: { |
| 114 | metadata: PullRequestContextMetadata; | 116 | metadata: PullRequestContextMetadata; |
| 117 | environmentId: EnvironmentId | null; | ||
| 115 | label: string; | 118 | label: string; |
| 116 | kindLabel: string; | 119 | kindLabel: string; |
| 117 | className: string; | 120 | className: string; |
| ... | @@ -119,29 +122,40 @@ export function PullRequestChip(props: { | ... | @@ -119,29 +122,40 @@ export function PullRequestChip(props: { |
| 119 | copyMarkdown?: string; | 122 | copyMarkdown?: string; |
| 120 | onOpen: (event: MouseEvent<HTMLElement>, url: string) => void; | 123 | onOpen: (event: MouseEvent<HTMLElement>, url: string) => void; |
| 121 | }) { | 124 | }) { |
| 125 | const previewTarget = usePullRequestPreviewTarget(props.environmentId, props.metadata.url); | ||
| 126 | const button = ( | ||
| 127 | <Button | ||
| 128 | variant="chip" | ||
| 129 | className={cn( | ||
| 130 | props.className, | ||
| 131 | CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME, | ||
| 132 | CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME, | ||
| 133 | "cursor-pointer", | ||
| 134 | )} | ||
| 135 | aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`} | ||
| 136 | data-markdown-copy={props.copyMarkdown} | ||
| 137 | onClick={(event) => props.onOpen(event, props.metadata.url)} | ||
| 138 | > | ||
| 139 | <PullRequestGlyph.pullRequest | ||
| 140 | className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")} | ||
| 141 | /> | ||
| 142 | <span className={props.labelClassName}>{props.label}</span> | ||
| 143 | </Button> | ||
| 144 | ); | ||
| 145 | if (previewTarget !== null) { | ||
| 146 | return ( | ||
| 147 | <PullRequestLinkPreview | ||
| 148 | link={button} | ||
| 149 | originalUrl={props.metadata.url} | ||
| 150 | target={previewTarget} | ||
| 151 | confirmBeforeOpen={false} | ||
| 152 | fallback={<PullRequestContextDetails metadata={props.metadata} />} | ||
| 153 | /> | ||
| 154 | ); | ||
| 155 | } | ||
| 122 | return ( | 156 | return ( |
| 123 | <Tooltip> | 157 | <Tooltip> |
| 124 | <TooltipTrigger | 158 | <TooltipTrigger render={button} /> |
| 125 | render={ | ||
| 126 | <Button | ||
| 127 | variant="chip" | ||
| 128 | className={cn( | ||
| 129 | props.className, | ||
| 130 | CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME, | ||
| 131 | CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME, | ||
| 132 | "cursor-pointer", | ||
| 133 | )} | ||
| 134 | aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`} | ||
| 135 | data-markdown-copy={props.copyMarkdown} | ||
| 136 | onClick={(event) => props.onOpen(event, props.metadata.url)} | ||
| 137 | > | ||
| 138 | <PullRequestGlyph.pullRequest | ||
| 139 | className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")} | ||
| 140 | /> | ||
| 141 | <span className={props.labelClassName}>{props.label}</span> | ||
| 142 | </Button> | ||
| 143 | } | ||
| 144 | /> | ||
| 145 | <TooltipPopup side="top"> | 159 | <TooltipPopup side="top"> |
| 146 | <PullRequestContextDetails metadata={props.metadata} /> | 160 | <PullRequestContextDetails metadata={props.metadata} /> |
| 147 | </TooltipPopup> | 161 | </TooltipPopup> |
apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx+44-30| ... | @@ -1,6 +1,13 @@ | ... | @@ -1,6 +1,13 @@ |
| 1 | import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; | 1 | import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; |
| 2 | import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; |
| 3 | import { cloneElement, useState, type ComponentPropsWithoutRef, type ReactElement } from "react"; | 3 | import { |
| 4 | cloneElement, | ||
| 5 | useState, | ||
| 6 | type ComponentPropsWithoutRef, | ||
| 7 | type MouseEvent, | ||
| 8 | type ReactElement, | ||
| 9 | type ReactNode, | ||
| 10 | } from "react"; | ||
| 4 | 11 | ||
| 5 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | 12 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 6 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 13 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| ... | @@ -15,7 +22,9 @@ interface PullRequestLinkPreviewTarget { | ... | @@ -15,7 +22,9 @@ interface PullRequestLinkPreviewTarget { |
| 15 | readonly input: PullRequestRef; | 22 | readonly input: PullRequestRef; |
| 16 | } | 23 | } |
| 17 | 24 | ||
| 18 | type PullRequestLinkElement = ReactElement<ComponentPropsWithoutRef<"a">>; | 25 | type PullRequestLinkElement = ReactElement< |
| 26 | ComponentPropsWithoutRef<"a"> | ComponentPropsWithoutRef<"button"> | ||
| 27 | >; | ||
| 19 | 28 | ||
| 20 | export function PullRequestLinkPreview({ | 29 | export function PullRequestLinkPreview({ |
| 21 | link, | 30 | link, |
| ... | @@ -24,13 +33,15 @@ export function PullRequestLinkPreview({ | ... | @@ -24,13 +33,15 @@ export function PullRequestLinkPreview({ |
| 24 | confirmBeforeOpen, | 33 | confirmBeforeOpen, |
| 25 | onOpenPullRequest, | 34 | onOpenPullRequest, |
| 26 | onOpenFallback, | 35 | onOpenFallback, |
| 36 | fallback, | ||
| 27 | }: { | 37 | }: { |
| 28 | link: PullRequestLinkElement; | 38 | link: PullRequestLinkElement; |
| 29 | originalUrl: string; | 39 | originalUrl: string; |
| 30 | target: PullRequestLinkPreviewTarget; | 40 | target: PullRequestLinkPreviewTarget; |
| 31 | confirmBeforeOpen: boolean; | 41 | confirmBeforeOpen?: boolean; |
| 32 | onOpenPullRequest: (url: string) => boolean; | 42 | onOpenPullRequest?: (url: string) => boolean; |
| 33 | onOpenFallback: (url: string) => Promise<void>; | 43 | onOpenFallback?: (url: string) => Promise<void>; |
| 44 | fallback?: ReactNode; | ||
| 34 | }) { | 45 | }) { |
| 35 | const [open, setOpen] = useState(false); | 46 | const [open, setOpen] = useState(false); |
| 36 | const [resolvingClick, setResolvingClick] = useState(false); | 47 | const [resolvingClick, setResolvingClick] = useState(false); |
| ... | @@ -46,28 +57,29 @@ export function PullRequestLinkPreview({ | ... | @@ -46,28 +57,29 @@ export function PullRequestLinkPreview({ |
| 46 | reportFailure: false, | 57 | reportFailure: false, |
| 47 | reportDefect: false, | 58 | reportDefect: false, |
| 48 | }); | 59 | }); |
| 49 | const trigger = confirmBeforeOpen | 60 | const trigger = |
| 50 | ? cloneElement(link, { | 61 | confirmBeforeOpen === true |
| 51 | onClick: (event) => { | 62 | ? cloneElement(link, { |
| 52 | if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; | 63 | onClick: (event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => { |
| 53 | event.preventDefault(); | 64 | if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; |
| 54 | event.stopPropagation(); | 65 | event.preventDefault(); |
| 55 | if (resolvingClick) return; | 66 | event.stopPropagation(); |
| 56 | setOpen(false); | 67 | if (resolvingClick) return; |
| 57 | setResolvingClick(true); | 68 | setOpen(false); |
| 58 | void readPreview(target) | 69 | setResolvingClick(true); |
| 59 | .then(async (result) => { | 70 | void readPreview(target) |
| 60 | if (isAtomCommandInterrupted(result)) return; | 71 | .then(async (result) => { |
| 61 | if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return; | 72 | if (isAtomCommandInterrupted(result)) return; |
| 62 | await onOpenFallback(originalUrl); | 73 | if (result._tag === "Success" && onOpenPullRequest?.(result.value.url)) return; |
| 63 | }) | 74 | await onOpenFallback?.(originalUrl); |
| 64 | .catch((error: unknown) => { | 75 | }) |
| 65 | console.error("[pull-request-link-preview] failed to open link", error); | 76 | .catch((error: unknown) => { |
| 66 | }) | 77 | console.error("[pull-request-link-preview] failed to open link", error); |
| 67 | .finally(() => setResolvingClick(false)); | 78 | }) |
| 68 | }, | 79 | .finally(() => setResolvingClick(false)); |
| 69 | }) | 80 | }, |
| 70 | : link; | 81 | }) |
| 82 | : link; | ||
| 71 | const detail = detailQuery.data; | 83 | const detail = detailQuery.data; |
| 72 | const state = | 84 | const state = |
| 73 | detail === null | 85 | detail === null |
| ... | @@ -86,9 +98,11 @@ export function PullRequestLinkPreview({ | ... | @@ -86,9 +98,11 @@ export function PullRequestLinkPreview({ |
| 86 | {detail !== null || detailQuery.error !== null ? ( | 98 | {detail !== null || detailQuery.error !== null ? ( |
| 87 | <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3"> | 99 | <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3"> |
| 88 | {detail === null ? ( | 100 | {detail === null ? ( |
| 89 | <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere"> | 101 | (fallback ?? ( |
| 90 | {originalUrl} | 102 | <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere"> |
| 91 | </p> | 103 | {originalUrl} |
| 104 | </p> | ||
| 105 | )) | ||
| 92 | ) : ( | 106 | ) : ( |
| 93 | <div className="min-w-0"> | 107 | <div className="min-w-0"> |
| 94 | <div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground"> | 108 | <div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground"> |
apps/web/src/lib/openPullRequestLink.ts+49-2| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useNavigate } from "@tanstack/react-router"; | 3 | import { useNavigate } from "@tanstack/react-router"; |
| 3 | import { type MouseEvent, useCallback } from "react"; | 4 | import { type MouseEvent, useCallback, useMemo } from "react"; |
| 4 | 5 | ||
| 5 | import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; | 6 | import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; |
| 6 | import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; | 7 | import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; |
| ... | @@ -15,6 +16,7 @@ import { useRightPanelStore } from "../rightPanelStore"; | ... | @@ -15,6 +16,7 @@ import { useRightPanelStore } from "../rightPanelStore"; |
| 15 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 16 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 16 | 17 | ||
| 17 | import { useProjects, useServerConfigs } from "../state/entities"; | 18 | import { useProjects, useServerConfigs } from "../state/entities"; |
| 19 | import { serverEnvironment } from "../state/server"; | ||
| 18 | import { usePrimaryEnvironmentId } from "../state/environments"; | 20 | import { usePrimaryEnvironmentId } from "../state/environments"; |
| 19 | 21 | ||
| 20 | export { | 22 | export { |
| ... | @@ -93,6 +95,51 @@ export function findProjectForChangeRequest( | ... | @@ -93,6 +95,51 @@ export function findProjectForChangeRequest( |
| 93 | }); | 95 | }); |
| 94 | } | 96 | } |
| 95 | 97 | ||
| 98 | export function resolvePullRequestPreviewTarget({ | ||
| 99 | environmentId, | ||
| 100 | projects, | ||
| 101 | pullRequestsEnabled, | ||
| 102 | url, | ||
| 103 | }: { | ||
| 104 | environmentId: EnvironmentId | null; | ||
| 105 | projects: ReadonlyArray<EnvironmentProject>; | ||
| 106 | pullRequestsEnabled: boolean; | ||
| 107 | url: string; | ||
| 108 | }): { environmentId: EnvironmentId; input: PullRequestRef } | null { | ||
| 109 | if (!pullRequestsEnabled || environmentId === null) return null; | ||
| 110 | const parsed = parseChangeRequestUrl(url); | ||
| 111 | if (parsed === null) return null; | ||
| 112 | const project = findProjectForChangeRequest( | ||
| 113 | projects.filter((candidate) => candidate.environmentId === environmentId), | ||
| 114 | parsed, | ||
| 115 | ); | ||
| 116 | if (project === undefined) return null; | ||
| 117 | return { | ||
| 118 | environmentId, | ||
| 119 | input: { | ||
| 120 | projectId: project.id, | ||
| 121 | host: parsed.authority ?? parsed.host, | ||
| 122 | repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository, | ||
| 123 | number: parsed.number, | ||
| 124 | }, | ||
| 125 | }; | ||
| 126 | } | ||
| 127 | |||
| 128 | export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, url: string) { | ||
| 129 | const projects = useProjects(); | ||
| 130 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); | ||
| 131 | return useMemo( | ||
| 132 | () => | ||
| 133 | resolvePullRequestPreviewTarget({ | ||
| 134 | environmentId, | ||
| 135 | projects, | ||
| 136 | pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true, | ||
| 137 | url, | ||
| 138 | }), | ||
| 139 | [environmentId, projects, serverConfig, url], | ||
| 140 | ); | ||
| 141 | } | ||
| 142 | |||
| 96 | /** | 143 | /** |
| 97 | * Any project checked out from the link's host. Thread links are host-level, so a pull request | 144 | * Any project checked out from the link's host. Thread links are host-level, so a pull request |
| 98 | * from a repository nobody has checked out is still linkable as long as one project on that | 145 | * from a repository nobody has checked out is still linkable as long as one project on that |