| ... | @@ -1,5 +1,4 @@ | ... | @@ -1,5 +1,4 @@ |
| 1 | import { | 1 | import { |
| 2 | ChevronDownIcon, | | |
| 3 | ChevronsLeftRightEllipsisIcon, | 2 | ChevronsLeftRightEllipsisIcon, |
| 4 | PlusIcon, | 3 | PlusIcon, |
| 5 | QrCodeIcon, | 4 | QrCodeIcon, |
| ... | @@ -8,7 +7,7 @@ import { | ... | @@ -8,7 +7,7 @@ import { |
| 8 | TriangleAlertIcon, | 7 | TriangleAlertIcon, |
| 9 | } from "lucide-react"; | 8 | } from "lucide-react"; |
| 10 | import { useAtomValue } from "@effect/atom-react"; | 9 | import { useAtomValue } from "@effect/atom-react"; |
| 11 | import { type ReactNode, memo, useCallback, useMemo, useState } from "react"; | 10 | import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react"; |
| 12 | import { | 11 | import { |
| 13 | AuthAccessReadScope, | 12 | AuthAccessReadScope, |
| 14 | AuthAccessWriteScope, | 13 | AuthAccessWriteScope, |
| ... | @@ -42,7 +41,11 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | ... | @@ -42,7 +41,11 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 42 | import { cn } from "../../lib/utils"; | 41 | import { cn } from "../../lib/utils"; |
| 43 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; | 42 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; |
| 44 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; | 43 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; |
| 45 | import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; | 44 | import { |
| | 45 | applyWslEnableSelection, |
| | 46 | isQrShareableEndpoint, |
| | 47 | selectQrEndpointOption, |
| | 48 | } from "./ConnectionsSettings.logic"; |
| 46 | import { | 49 | import { |
| 47 | SettingsPageContainer, | 50 | SettingsPageContainer, |
| 48 | SettingsRow, | 51 | SettingsRow, |
| ... | @@ -82,17 +85,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; | ... | @@ -82,17 +85,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 82 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 85 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 83 | import { Button } from "../ui/button"; | 86 | import { Button } from "../ui/button"; |
| 84 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; | 87 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; |
| 85 | import { Group, GroupSeparator } from "../ui/group"; | | |
| 86 | import { AnimatedHeight } from "../AnimatedHeight"; | 88 | import { AnimatedHeight } from "../AnimatedHeight"; |
| 87 | import { | | |
| 88 | Menu, | | |
| 89 | MenuGroup, | | |
| 90 | MenuGroupLabel, | | |
| 91 | MenuItem, | | |
| 92 | MenuPopup, | | |
| 93 | MenuSeparator, | | |
| 94 | MenuTrigger, | | |
| 95 | } from "../ui/menu"; | | |
| 96 | import { Textarea } from "../ui/textarea"; | 89 | import { Textarea } from "../ui/textarea"; |
| 97 | import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; | 90 | import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; |
| 98 | import { readHostedPairingRequest } from "../../hostedPairing"; | 91 | import { readHostedPairingRequest } from "../../hostedPairing"; |
| ... | @@ -506,6 +499,22 @@ function isHostedAppPairingUrl(value: string): boolean { | ... | @@ -506,6 +499,22 @@ function isHostedAppPairingUrl(value: string): boolean { |
| 506 | } | 499 | } |
| 507 | } | 500 | } |
| 508 | | 501 | |
| | 502 | function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string { |
| | 503 | if (isHostedAppPairingUrl(url)) { |
| | 504 | return "Opens the hosted app, no install needed"; |
| | 505 | } |
| | 506 | switch (endpoint.reachability) { |
| | 507 | case "lan": |
| | 508 | return "Devices on the same network"; |
| | 509 | case "private-network": |
| | 510 | return "Devices on your private network"; |
| | 511 | case "public": |
| | 512 | return "Reachable from anywhere"; |
| | 513 | case "loopback": |
| | 514 | return "Clients on this machine"; |
| | 515 | } |
| | 516 | } |
| | 517 | |
| 509 | type PairingLinkListRowProps = { | 518 | type PairingLinkListRowProps = { |
| 510 | pairingLink: ServerPairingLinkRecord; | 519 | pairingLink: ServerPairingLinkRecord; |
| 511 | endpointUrl: string | null | undefined; | 520 | endpointUrl: string | null | undefined; |
| ... | @@ -531,6 +540,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -531,6 +540,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 531 | [pairingLink.expiresAt], | 540 | [pairingLink.expiresAt], |
| 532 | ); | 541 | ); |
| 533 | const [isRevealDialogOpen, setIsRevealDialogOpen] = useState(false); | 542 | const [isRevealDialogOpen, setIsRevealDialogOpen] = useState(false); |
| | 543 | const [isQrPanelOpen, setIsQrPanelOpen] = useState(false); |
| | 544 | // Ephemeral per-row choice of which endpoint the QR encodes (AdvertisedEndpoint.id); |
| | 545 | // null falls back to the saved default endpoint. |
| | 546 | const [qrEndpointId, setQrEndpointId] = useState<string | null>(null); |
| | 547 | const qrPanelId = useId(); |
| 534 | | 548 | |
| 535 | const currentOriginPairingUrl = useMemo( | 549 | const currentOriginPairingUrl = useMemo( |
| 536 | () => resolveCurrentOriginPairingUrl(pairingLink.credential), | 550 | () => resolveCurrentOriginPairingUrl(pairingLink.credential), |
| ... | @@ -549,10 +563,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -549,10 +563,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 549 | }, [defaultEndpointKey, endpoints, pairingLink.credential]); | 563 | }, [defaultEndpointKey, endpoints, pairingLink.credential]); |
| 550 | const endpointCopyOptions = useMemo(() => { | 564 | const endpointCopyOptions = useMemo(() => { |
| 551 | const options: Array<{ | 565 | const options: Array<{ |
| 552 | readonly key: string; | 566 | readonly id: string; |
| | 567 | readonly preferenceKey: string; |
| 553 | readonly label: string; | 568 | readonly label: string; |
| 554 | readonly url: string; | 569 | readonly url: string; |
| 555 | readonly detail: string; | 570 | readonly detail: string; |
| | 571 | readonly qrShareable: boolean; |
| 556 | }> = []; | 572 | }> = []; |
| 557 | for (const endpoint of endpoints) { | 573 | for (const endpoint of endpoints) { |
| 558 | if (endpoint.status === "unavailable") { | 574 | if (endpoint.status === "unavailable") { |
| ... | @@ -560,10 +576,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -560,10 +576,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 560 | } | 576 | } |
| 561 | const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential); | 577 | const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential); |
| 562 | options.push({ | 578 | options.push({ |
| 563 | key: endpointDefaultPreferenceKey(endpoint), | 579 | id: endpoint.id, |
| | 580 | preferenceKey: endpointDefaultPreferenceKey(endpoint), |
| 564 | label: endpoint.label, | 581 | label: endpoint.label, |
| 565 | url, | 582 | url, |
| 566 | detail: isHostedAppPairingUrl(url) ? "Hosted app link" : "Backend pairing URL", | 583 | detail: endpointShareHint(endpoint, url), |
| | 584 | qrShareable: isQrShareableEndpoint(endpoint), |
| 567 | }); | 585 | }); |
| 568 | } | 586 | } |
| 569 | return options; | 587 | return options; |
| ... | @@ -575,16 +593,25 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -575,16 +593,25 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 575 | : isLoopbackHostname(window.location.hostname) | 593 | : isLoopbackHostname(window.location.hostname) |
| 576 | ? null | 594 | ? null |
| 577 | : currentOriginPairingUrl); | 595 | : currentOriginPairingUrl); |
| 578 | const revealValue = shareablePairingUrl ?? pairingLink.credential; | 596 | // Value of the copy attempt that last failed. The clipboard-failure reveal |
| 579 | const isShareableHostedAppPairingUrl = | 597 | // dialog must show exactly what failed to copy, not the row's default URL. |
| 580 | shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl); | 598 | const [failedCopyValue, setFailedCopyValue] = useState<string | null>(null); |
| | 599 | const revealValue = failedCopyValue ?? shareablePairingUrl ?? pairingLink.credential; |
| | 600 | const isRevealValueUrl = revealValue !== pairingLink.credential; |
| | 601 | const isRevealValueHostedAppPairingUrl = isRevealValueUrl && isHostedAppPairingUrl(revealValue); |
| | 602 | // Never render a QR for a loopback URL, even in the manual-copy fallback. |
| | 603 | const isRevealValueQrShareable = |
| | 604 | endpointCopyOptions.find((option) => option.url === revealValue)?.qrShareable ?? true; |
| 581 | const canCopyToClipboard = | 605 | const canCopyToClipboard = |
| 582 | typeof window !== "undefined" && | 606 | typeof window !== "undefined" && |
| 583 | window.isSecureContext && | 607 | window.isSecureContext && |
| 584 | navigator.clipboard?.writeText != null; | 608 | navigator.clipboard?.writeText != null; |
| 585 | | 609 | |
| 586 | const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({ | 610 | const { copyToClipboard } = useCopyToClipboard<{ |
| 587 | onCopy: (kind) => { | 611 | value: string; |
| | 612 | kind: "code" | "hosted-link" | "link"; |
| | 613 | }>({ |
| | 614 | onCopy: ({ kind }) => { |
| 588 | toastManager.add({ | 615 | toastManager.add({ |
| 589 | type: "success", | 616 | type: "success", |
| 590 | title: | 617 | title: |
| ... | @@ -601,7 +628,10 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -601,7 +628,10 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 601 | : "Paste it into another client to finish pairing.", | 628 | : "Paste it into another client to finish pairing.", |
| 602 | }); | 629 | }); |
| 603 | }, | 630 | }, |
| 604 | onError: (error, kind) => { | 631 | onError: (error, { value, kind }) => { |
| | 632 | // Captured per attempt so concurrent copies cannot make the dialog |
| | 633 | // reveal a different value than the one that failed. |
| | 634 | setFailedCopyValue(value); |
| 605 | setIsRevealDialogOpen(true); | 635 | setIsRevealDialogOpen(true); |
| 606 | toastManager.add( | 636 | toastManager.add( |
| 607 | stackedThreadToast({ | 637 | stackedThreadToast({ |
| ... | @@ -621,7 +651,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -621,7 +651,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 621 | | 651 | |
| 622 | const copyPairingValue = useCallback( | 652 | const copyPairingValue = useCallback( |
| 623 | (value: string, kind: "code" | "hosted-link" | "link") => { | 653 | (value: string, kind: "code" | "hosted-link" | "link") => { |
| 624 | copyToClipboard(value, kind); | 654 | copyToClipboard(value, { value, kind }); |
| 625 | }, | 655 | }, |
| 626 | [copyToClipboard], | 656 | [copyToClipboard], |
| 627 | ); | 657 | ); |
| ... | @@ -635,97 +665,19 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -635,97 +665,19 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 635 | copyPairingValue(pairingLink.credential, "code"); | 665 | copyPairingValue(pairingLink.credential, "code"); |
| 636 | }, [copyPairingValue, pairingLink.credential]); | 666 | }, [copyPairingValue, pairingLink.credential]); |
| 637 | | 667 | |
| 638 | const handleCopyDefaultLink = useCallback(() => { | | |
| 639 | if (!shareablePairingUrl) return; | | |
| 640 | copyPairingValue(shareablePairingUrl, copyKindForUrl(shareablePairingUrl)); | | |
| 641 | }, [copyKindForUrl, copyPairingValue, shareablePairingUrl]); | | |
| 642 | | | |
| 643 | const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); | 668 | const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); |
| 644 | | 669 | |
| 645 | const primaryLabel = pairingLink.label ?? "Pairing link"; | 670 | const primaryLabel = pairingLink.label ?? "Pairing link"; |
| 646 | const defaultEndpointCopyOption = | 671 | const selectedQrOption = selectQrEndpointOption( |
| 647 | endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ?? | 672 | endpointCopyOptions, |
| 648 | endpointCopyOptions[0] ?? | 673 | qrEndpointId, |
| 649 | null; | 674 | defaultEndpointKey, |
| 650 | const defaultEndpointCopyLabel = defaultEndpointCopyOption?.label ?? "URL"; | 675 | ); |
| 651 | const backendEndpointCopyOptions = endpointCopyOptions.filter( | 676 | const qrPairingUrl = selectedQrOption?.url ?? shareablePairingUrl; |
| 652 | (option) => !isHostedAppPairingUrl(option.url), | 677 | // With no endpoint list the fallback is never loopback: selectPairingEndpoint |
| 653 | ); | 678 | // skips loopback and the current-origin fallback is guarded by |
| 654 | const hostedEndpointCopyOptions = endpointCopyOptions.filter((option) => | 679 | // isLoopbackHostname, so only an explicit loopback selection hides the QR. |
| 655 | isHostedAppPairingUrl(option.url), | 680 | const canRenderQrForSelection = selectedQrOption?.qrShareable ?? true; |
| 656 | ); | | |
| 657 | const renderEndpointMenuItems = ( | | |
| 658 | options: typeof endpointCopyOptions = endpointCopyOptions, | | |
| 659 | renderDetail = true, | | |
| 660 | ) => | | |
| 661 | options.map((option) => ( | | |
| 662 | <MenuItem | | |
| 663 | key={option.key} | | |
| 664 | onClick={() => copyPairingValue(option.url, copyKindForUrl(option.url))} | | |
| 665 | > | | |
| 666 | <span className="min-w-0 flex-1"> | | |
| 667 | <span className="block truncate">{option.label}</span> | | |
| 668 | {renderDetail ? ( | | |
| 669 | <span className="block truncate text-[11px] text-muted-foreground"> | | |
| 670 | {option.detail} | | |
| 671 | </span> | | |
| 672 | ) : null} | | |
| 673 | </span> | | |
| 674 | </MenuItem> | | |
| 675 | )); | | |
| 676 | const renderPairingCodeMenuItem = (renderDetail = true) => ( | | |
| 677 | <MenuItem onClick={handleCopyCode}> | | |
| 678 | <span className="min-w-0 flex-1"> | | |
| 679 | <span className="block truncate">Copy code</span> | | |
| 680 | {renderDetail ? ( | | |
| 681 | <span className="block truncate text-[11px] text-muted-foreground">Token only</span> | | |
| 682 | ) : null} | | |
| 683 | </span> | | |
| 684 | </MenuItem> | | |
| 685 | ); | | |
| 686 | const renderCompactEndpointGroup = ( | | |
| 687 | label: string, | | |
| 688 | options: typeof endpointCopyOptions, | | |
| 689 | includeSeparator: boolean, | | |
| 690 | ) => | | |
| 691 | options.length > 0 ? ( | | |
| 692 | <> | | |
| 693 | {includeSeparator ? <MenuSeparator /> : null} | | |
| 694 | <MenuGroup> | | |
| 695 | <MenuGroupLabel>{label}</MenuGroupLabel> | | |
| 696 | {renderEndpointMenuItems(options, false)} | | |
| 697 | </MenuGroup> | | |
| 698 | </> | | |
| 699 | ) : null; | | |
| 700 | const renderGroupedCopyMenuItems = (options?: { codeFirst?: boolean }) => ( | | |
| 701 | <> | | |
| 702 | {options?.codeFirst ? ( | | |
| 703 | <> | | |
| 704 | <MenuGroup> | | |
| 705 | <MenuGroupLabel>Pairing code</MenuGroupLabel> | | |
| 706 | {renderPairingCodeMenuItem(false)} | | |
| 707 | </MenuGroup> | | |
| 708 | {endpointCopyOptions.length > 0 ? <MenuSeparator /> : null} | | |
| 709 | </> | | |
| 710 | ) : null} | | |
| 711 | {renderCompactEndpointGroup("Pairing URLs", backendEndpointCopyOptions, false)} | | |
| 712 | {renderCompactEndpointGroup( | | |
| 713 | "Hosted app link", | | |
| 714 | hostedEndpointCopyOptions, | | |
| 715 | backendEndpointCopyOptions.length > 0, | | |
| 716 | )} | | |
| 717 | {!options?.codeFirst ? ( | | |
| 718 | <> | | |
| 719 | {endpointCopyOptions.length > 0 ? <MenuSeparator /> : null} | | |
| 720 | <MenuGroup> | | |
| 721 | <MenuGroupLabel>Pairing code</MenuGroupLabel> | | |
| 722 | {renderPairingCodeMenuItem(false)} | | |
| 723 | </MenuGroup> | | |
| 724 | </> | | |
| 725 | ) : null} | | |
| 726 | </> | | |
| 727 | ); | | |
| 728 | | | |
| 729 | if (expiresAtMs <= nowMs) { | 681 | if (expiresAtMs <= nowMs) { |
| 730 | return null; | 682 | return null; |
| 731 | } | 683 | } |
| ... | @@ -740,35 +692,6 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -740,35 +692,6 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 740 | dotClassName="bg-amber-400" | 692 | dotClassName="bg-amber-400" |
| 741 | /> | 693 | /> |
| 742 | <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3> | 694 | <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3> |
| 743 | <Popover> | | |
| 744 | {shareablePairingUrl ? ( | | |
| 745 | <> | | |
| 746 | <PopoverTrigger | | |
| 747 | openOnHover | | |
| 748 | delay={250} | | |
| 749 | closeDelay={100} | | |
| 750 | render={ | | |
| 751 | <button | | |
| 752 | type="button" | | |
| 753 | className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground/50 outline-none hover:text-foreground" | | |
| 754 | aria-label="Show QR code" | | |
| 755 | /> | | |
| 756 | } | | |
| 757 | > | | |
| 758 | <QrCodeIcon aria-hidden className="size-3" /> | | |
| 759 | </PopoverTrigger> | | |
| 760 | <PopoverPopup side="top" align="start" tooltipStyle className="w-max"> | | |
| 761 | <QRCodeSvg | | |
| 762 | value={shareablePairingUrl} | | |
| 763 | size={88} | | |
| 764 | level="M" | | |
| 765 | marginSize={2} | | |
| 766 | title="Pairing link — scan to open on another device" | | |
| 767 | /> | | |
| 768 | </PopoverPopup> | | |
| 769 | </> | | |
| 770 | ) : null} | | |
| 771 | </Popover> | | |
| 772 | </div> | 695 | </div> |
| 773 | <p className="text-xs text-muted-foreground" title={expiresAbsolute}> | 696 | <p className="text-xs text-muted-foreground" title={expiresAbsolute}> |
| 774 | {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} | 697 | {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} |
| ... | @@ -782,46 +705,31 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -782,46 +705,31 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 782 | ) : null} | 705 | ) : null} |
| 783 | </div> | 706 | </div> |
| 784 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | 707 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 785 | <Dialog open={isRevealDialogOpen} onOpenChange={setIsRevealDialogOpen}> | 708 | {shareablePairingUrl && canCopyToClipboard ? ( |
| | 709 | <Button |
| | 710 | size="xs" |
| | 711 | variant="outline" |
| | 712 | aria-expanded={isQrPanelOpen} |
| | 713 | aria-controls={qrPanelId} |
| | 714 | onClick={() => setIsQrPanelOpen((open) => !open)} |
| | 715 | > |
| | 716 | <QrCodeIcon aria-hidden /> |
| | 717 | Share |
| | 718 | </Button> |
| | 719 | ) : null} |
| | 720 | <Dialog |
| | 721 | open={isRevealDialogOpen} |
| | 722 | onOpenChange={(open) => { |
| | 723 | setIsRevealDialogOpen(open); |
| | 724 | if (!open) setFailedCopyValue(null); |
| | 725 | }} |
| | 726 | > |
| 786 | {canCopyToClipboard ? ( | 727 | {canCopyToClipboard ? ( |
| 787 | <> | 728 | shareablePairingUrl ? null : ( |
| 788 | {shareablePairingUrl ? ( | 729 | <Button size="xs" variant="outline" onClick={handleCopyCode}> |
| 789 | <Group aria-label="Copy selected endpoint"> | 730 | Copy code |
| 790 | <Button | 731 | </Button> |
| 791 | size="xs" | 732 | ) |
| 792 | variant="outline" | | |
| 793 | className="max-w-56" | | |
| 794 | title={`Copy pairing URL for: ${defaultEndpointCopyLabel}`} | | |
| 795 | onClick={handleCopyDefaultLink} | | |
| 796 | > | | |
| 797 | <span className="truncate"> | | |
| 798 | Copy pairing URL for: {defaultEndpointCopyLabel} | | |
| 799 | </span> | | |
| 800 | </Button> | | |
| 801 | <GroupSeparator /> | | |
| 802 | <Menu> | | |
| 803 | <MenuTrigger | | |
| 804 | render={ | | |
| 805 | <Button | | |
| 806 | size="icon-xs" | | |
| 807 | variant="outline" | | |
| 808 | aria-label="Choose endpoint to copy" | | |
| 809 | /> | | |
| 810 | } | | |
| 811 | > | | |
| 812 | <ChevronDownIcon className="size-3.5" /> | | |
| 813 | </MenuTrigger> | | |
| 814 | <MenuPopup align="end" className="min-w-60"> | | |
| 815 | {renderGroupedCopyMenuItems()} | | |
| 816 | </MenuPopup> | | |
| 817 | </Menu> | | |
| 818 | </Group> | | |
| 819 | ) : ( | | |
| 820 | <Button size="xs" variant="outline" onClick={handleCopyCode}> | | |
| 821 | Copy code | | |
| 822 | </Button> | | |
| 823 | )} | | |
| 824 | </> | | |
| 825 | ) : ( | 733 | ) : ( |
| 826 | <DialogTrigger render={<Button size="xs" variant="outline" />}> | 734 | <DialogTrigger render={<Button size="xs" variant="outline" />}> |
| 827 | {shareablePairingUrl ? "Show link" : "Show code"} | 735 | {shareablePairingUrl ? "Show link" : "Show code"} |
| ... | @@ -830,15 +738,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -830,15 +738,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 830 | <DialogPopup className="max-w-md"> | 738 | <DialogPopup className="max-w-md"> |
| 831 | <DialogHeader> | 739 | <DialogHeader> |
| 832 | <DialogTitle> | 740 | <DialogTitle> |
| 833 | {shareablePairingUrl | 741 | {isRevealValueUrl |
| 834 | ? isShareableHostedAppPairingUrl | 742 | ? isRevealValueHostedAppPairingUrl |
| 835 | ? "Hosted app pairing link" | 743 | ? "Hosted app pairing link" |
| 836 | : "Pairing link" | 744 | : "Pairing link" |
| 837 | : "Pairing code"} | 745 | : "Pairing code"} |
| 838 | </DialogTitle> | 746 | </DialogTitle> |
| 839 | <DialogDescription> | 747 | <DialogDescription> |
| 840 | {shareablePairingUrl | 748 | {isRevealValueUrl |
| 841 | ? isShareableHostedAppPairingUrl | 749 | ? isRevealValueHostedAppPairingUrl |
| 842 | ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect." | 750 | ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect." |
| 843 | : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." | 751 | : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." |
| 844 | : "Clipboard copy is unavailable here. Manually copy this code into another client."} | 752 | : "Clipboard copy is unavailable here. Manually copy this code into another client."} |
| ... | @@ -848,15 +756,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -848,15 +756,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 848 | <Textarea | 756 | <Textarea |
| 849 | readOnly | 757 | readOnly |
| 850 | value={revealValue} | 758 | value={revealValue} |
| 851 | rows={shareablePairingUrl ? 4 : 3} | 759 | rows={isRevealValueUrl ? 4 : 3} |
| 852 | className="text-xs leading-relaxed" | 760 | className="text-xs leading-relaxed" |
| 853 | onFocus={(event) => event.currentTarget.select()} | 761 | onFocus={(event) => event.currentTarget.select()} |
| 854 | onClick={(event) => event.currentTarget.select()} | 762 | onClick={(event) => event.currentTarget.select()} |
| 855 | /> | 763 | /> |
| 856 | {shareablePairingUrl ? ( | 764 | {isRevealValueUrl && isRevealValueQrShareable ? ( |
| 857 | <div className="flex justify-center rounded-xl border border-border/60 bg-muted/30 p-4"> | 765 | <div className="flex justify-center rounded-xl border border-border/60 bg-muted/30 p-4"> |
| 858 | <QRCodeSvg | 766 | <QRCodeSvg |
| 859 | value={shareablePairingUrl} | 767 | value={revealValue} |
| 860 | size={132} | 768 | size={132} |
| 861 | level="M" | 769 | level="M" |
| 862 | marginSize={2} | 770 | marginSize={2} |
| ... | @@ -887,6 +795,91 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ | ... | @@ -887,6 +795,91 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 887 | </Button> | 795 | </Button> |
| 888 | </div> | 796 | </div> |
| 889 | </div> | 797 | </div> |
| | 798 | {isQrPanelOpen && qrPairingUrl !== null ? ( |
| | 799 | <div |
| | 800 | id={qrPanelId} |
| | 801 | className="mt-3 flex flex-col gap-4 border-t border-border/50 pt-3 sm:flex-row sm:items-start sm:justify-between" |
| | 802 | > |
| | 803 | <div className="min-w-0 flex-1 space-y-3"> |
| | 804 | {endpointCopyOptions.length > 1 ? ( |
| | 805 | <div |
| | 806 | className="space-y-1.5" |
| | 807 | role="radiogroup" |
| | 808 | aria-label="Endpoint the pairing QR code and URL use" |
| | 809 | > |
| | 810 | <p className="text-[11px] text-muted-foreground/70">Reach this machine via</p> |
| | 811 | {endpointCopyOptions.map((option) => { |
| | 812 | const isSelected = option.id === selectedQrOption?.id; |
| | 813 | return ( |
| | 814 | <button |
| | 815 | key={option.id} |
| | 816 | type="button" |
| | 817 | role="radio" |
| | 818 | aria-checked={isSelected} |
| | 819 | className={cn( |
| | 820 | "flex w-full items-baseline gap-2 rounded-lg border px-2.5 py-1.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring", |
| | 821 | isSelected |
| | 822 | ? "border-foreground/60 bg-muted/30" |
| | 823 | : "border-border/50 hover:bg-muted/20", |
| | 824 | )} |
| | 825 | onClick={() => setQrEndpointId(option.id)} |
| | 826 | > |
| | 827 | <span |
| | 828 | className={cn( |
| | 829 | "text-xs font-medium", |
| | 830 | isSelected ? "text-foreground" : "text-muted-foreground", |
| | 831 | )} |
| | 832 | > |
| | 833 | {option.label} |
| | 834 | </span> |
| | 835 | <span className="min-w-0 truncate text-[11px] text-muted-foreground/70"> |
| | 836 | {option.detail} |
| | 837 | </span> |
| | 838 | </button> |
| | 839 | ); |
| | 840 | })} |
| | 841 | </div> |
| | 842 | ) : null} |
| | 843 | <div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-2.5 py-1.5"> |
| | 844 | <code |
| | 845 | title={qrPairingUrl} |
| | 846 | className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground" |
| | 847 | > |
| | 848 | {qrPairingUrl} |
| | 849 | </code> |
| | 850 | <Button |
| | 851 | size="xs" |
| | 852 | variant="ghost" |
| | 853 | className="shrink-0" |
| | 854 | onClick={() => copyPairingValue(qrPairingUrl, copyKindForUrl(qrPairingUrl))} |
| | 855 | > |
| | 856 | Copy link |
| | 857 | </Button> |
| | 858 | </div> |
| | 859 | <Button size="xs" variant="ghost" onClick={handleCopyCode}> |
| | 860 | Copy code only |
| | 861 | </Button> |
| | 862 | </div> |
| | 863 | {canRenderQrForSelection ? ( |
| | 864 | <div className="w-fit shrink-0 self-center rounded-xl bg-white p-3 sm:self-start"> |
| | 865 | <QRCodeSvg |
| | 866 | value={qrPairingUrl} |
| | 867 | size={168} |
| | 868 | level="M" |
| | 869 | marginSize={1} |
| | 870 | title="Pairing link — scan to open on another device" |
| | 871 | /> |
| | 872 | </div> |
| | 873 | ) : ( |
| | 874 | <div className="flex size-[192px] shrink-0 items-center justify-center self-center rounded-xl border border-border/50 p-4 sm:self-start"> |
| | 875 | <p className="text-center text-[11px] text-muted-foreground/70"> |
| | 876 | No QR for this endpoint. Another device scanning a loopback link would dial itself; |
| | 877 | copy the URL for use on this machine instead. |
| | 878 | </p> |
| | 879 | </div> |
| | 880 | )} |
| | 881 | </div> |
| | 882 | ) : null} |
| 890 | </div> | 883 | </div> |
| 891 | ); | 884 | ); |
| 892 | }); | 885 | }); |