| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | 1 | import { |
| 2 | | ChevronDownIcon, |
| 3 | 2 | ChevronsLeftRightEllipsisIcon, |
| 4 | 3 | PlusIcon, |
| 5 | 4 | QrCodeIcon, |
| ... | ... | @@ -8,7 +7,7 @@ import { |
| 8 | 7 | TriangleAlertIcon, |
| 9 | 8 | } from "lucide-react"; |
| 10 | 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 | 11 | import { |
| 13 | 12 | AuthAccessReadScope, |
| 14 | 13 | AuthAccessWriteScope, |
| ... | ... | @@ -42,7 +41,11 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 42 | 41 | import { cn } from "../../lib/utils"; |
| 43 | 42 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; |
| 44 | 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 | 49 | import { |
| 47 | 50 | SettingsPageContainer, |
| 48 | 51 | SettingsRow, |
| ... | ... | @@ -82,17 +85,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 82 | 85 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 83 | 86 | import { Button } from "../ui/button"; |
| 84 | 87 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; |
| 85 | | import { Group, GroupSeparator } from "../ui/group"; |
| 86 | 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 | 89 | import { Textarea } from "../ui/textarea"; |
| 97 | 90 | import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; |
| 98 | 91 | import { readHostedPairingRequest } from "../../hostedPairing"; |
| ... | ... | @@ -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 | 518 | type PairingLinkListRowProps = { |
| 510 | 519 | pairingLink: ServerPairingLinkRecord; |
| 511 | 520 | endpointUrl: string | null | undefined; |
| ... | ... | @@ -531,6 +540,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 531 | 540 | [pairingLink.expiresAt], |
| 532 | 541 | ); |
| 533 | 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 | 549 | const currentOriginPairingUrl = useMemo( |
| 536 | 550 | () => resolveCurrentOriginPairingUrl(pairingLink.credential), |
| ... | ... | @@ -549,10 +563,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 549 | 563 | }, [defaultEndpointKey, endpoints, pairingLink.credential]); |
| 550 | 564 | const endpointCopyOptions = useMemo(() => { |
| 551 | 565 | const options: Array<{ |
| 552 | | readonly key: string; |
| 566 | readonly id: string; |
| 567 | readonly preferenceKey: string; |
| 553 | 568 | readonly label: string; |
| 554 | 569 | readonly url: string; |
| 555 | 570 | readonly detail: string; |
| 571 | readonly qrShareable: boolean; |
| 556 | 572 | }> = []; |
| 557 | 573 | for (const endpoint of endpoints) { |
| 558 | 574 | if (endpoint.status === "unavailable") { |
| ... | ... | @@ -560,10 +576,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 560 | 576 | } |
| 561 | 577 | const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential); |
| 562 | 578 | options.push({ |
| 563 | | key: endpointDefaultPreferenceKey(endpoint), |
| 579 | id: endpoint.id, |
| 580 | preferenceKey: endpointDefaultPreferenceKey(endpoint), |
| 564 | 581 | label: endpoint.label, |
| 565 | 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 | 587 | return options; |
| ... | ... | @@ -575,16 +593,25 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 575 | 593 | : isLoopbackHostname(window.location.hostname) |
| 576 | 594 | ? null |
| 577 | 595 | : currentOriginPairingUrl); |
| 578 | | const revealValue = shareablePairingUrl ?? pairingLink.credential; |
| 579 | | const isShareableHostedAppPairingUrl = |
| 580 | | shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl); |
| 596 | // Value of the copy attempt that last failed. The clipboard-failure reveal |
| 597 | // dialog must show exactly what failed to copy, not the row's default URL. |
| 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 | 605 | const canCopyToClipboard = |
| 582 | 606 | typeof window !== "undefined" && |
| 583 | 607 | window.isSecureContext && |
| 584 | 608 | navigator.clipboard?.writeText != null; |
| 585 | 609 | |
| 586 | | const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({ |
| 587 | | onCopy: (kind) => { |
| 610 | const { copyToClipboard } = useCopyToClipboard<{ |
| 611 | value: string; |
| 612 | kind: "code" | "hosted-link" | "link"; |
| 613 | }>({ |
| 614 | onCopy: ({ kind }) => { |
| 588 | 615 | toastManager.add({ |
| 589 | 616 | type: "success", |
| 590 | 617 | title: |
| ... | ... | @@ -601,7 +628,10 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 601 | 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 | 635 | setIsRevealDialogOpen(true); |
| 606 | 636 | toastManager.add( |
| 607 | 637 | stackedThreadToast({ |
| ... | ... | @@ -621,7 +651,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 621 | 651 | |
| 622 | 652 | const copyPairingValue = useCallback( |
| 623 | 653 | (value: string, kind: "code" | "hosted-link" | "link") => { |
| 624 | | copyToClipboard(value, kind); |
| 654 | copyToClipboard(value, { value, kind }); |
| 625 | 655 | }, |
| 626 | 656 | [copyToClipboard], |
| 627 | 657 | ); |
| ... | ... | @@ -635,97 +665,19 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 635 | 665 | copyPairingValue(pairingLink.credential, "code"); |
| 636 | 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 | 668 | const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); |
| 644 | 669 | |
| 645 | 670 | const primaryLabel = pairingLink.label ?? "Pairing link"; |
| 646 | | const defaultEndpointCopyOption = |
| 647 | | endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ?? |
| 648 | | endpointCopyOptions[0] ?? |
| 649 | | null; |
| 650 | | const defaultEndpointCopyLabel = defaultEndpointCopyOption?.label ?? "URL"; |
| 651 | | const backendEndpointCopyOptions = endpointCopyOptions.filter( |
| 652 | | (option) => !isHostedAppPairingUrl(option.url), |
| 653 | | ); |
| 654 | | const hostedEndpointCopyOptions = endpointCopyOptions.filter((option) => |
| 655 | | isHostedAppPairingUrl(option.url), |
| 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 | | |
| 671 | const selectedQrOption = selectQrEndpointOption( |
| 672 | endpointCopyOptions, |
| 673 | qrEndpointId, |
| 674 | defaultEndpointKey, |
| 675 | ); |
| 676 | const qrPairingUrl = selectedQrOption?.url ?? shareablePairingUrl; |
| 677 | // With no endpoint list the fallback is never loopback: selectPairingEndpoint |
| 678 | // skips loopback and the current-origin fallback is guarded by |
| 679 | // isLoopbackHostname, so only an explicit loopback selection hides the QR. |
| 680 | const canRenderQrForSelection = selectedQrOption?.qrShareable ?? true; |
| 729 | 681 | if (expiresAtMs <= nowMs) { |
| 730 | 682 | return null; |
| 731 | 683 | } |
| ... | ... | @@ -740,35 +692,6 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 740 | 692 | dotClassName="bg-amber-400" |
| 741 | 693 | /> |
| 742 | 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 | 695 | </div> |
| 773 | 696 | <p className="text-xs text-muted-foreground" title={expiresAbsolute}> |
| 774 | 697 | {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} |
| ... | ... | @@ -782,46 +705,31 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 782 | 705 | ) : null} |
| 783 | 706 | </div> |
| 784 | 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 | 727 | {canCopyToClipboard ? ( |
| 787 | | <> |
| 788 | | {shareablePairingUrl ? ( |
| 789 | | <Group aria-label="Copy selected endpoint"> |
| 790 | | <Button |
| 791 | | size="xs" |
| 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 | | </> |
| 728 | shareablePairingUrl ? null : ( |
| 729 | <Button size="xs" variant="outline" onClick={handleCopyCode}> |
| 730 | Copy code |
| 731 | </Button> |
| 732 | ) |
| 825 | 733 | ) : ( |
| 826 | 734 | <DialogTrigger render={<Button size="xs" variant="outline" />}> |
| 827 | 735 | {shareablePairingUrl ? "Show link" : "Show code"} |
| ... | ... | @@ -830,15 +738,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 830 | 738 | <DialogPopup className="max-w-md"> |
| 831 | 739 | <DialogHeader> |
| 832 | 740 | <DialogTitle> |
| 833 | | {shareablePairingUrl |
| 834 | | ? isShareableHostedAppPairingUrl |
| 741 | {isRevealValueUrl |
| 742 | ? isRevealValueHostedAppPairingUrl |
| 835 | 743 | ? "Hosted app pairing link" |
| 836 | 744 | : "Pairing link" |
| 837 | 745 | : "Pairing code"} |
| 838 | 746 | </DialogTitle> |
| 839 | 747 | <DialogDescription> |
| 840 | | {shareablePairingUrl |
| 841 | | ? isShareableHostedAppPairingUrl |
| 748 | {isRevealValueUrl |
| 749 | ? isRevealValueHostedAppPairingUrl |
| 842 | 750 | ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect." |
| 843 | 751 | : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." |
| 844 | 752 | : "Clipboard copy is unavailable here. Manually copy this code into another client."} |
| ... | ... | @@ -848,15 +756,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 848 | 756 | <Textarea |
| 849 | 757 | readOnly |
| 850 | 758 | value={revealValue} |
| 851 | | rows={shareablePairingUrl ? 4 : 3} |
| 759 | rows={isRevealValueUrl ? 4 : 3} |
| 852 | 760 | className="text-xs leading-relaxed" |
| 853 | 761 | onFocus={(event) => event.currentTarget.select()} |
| 854 | 762 | onClick={(event) => event.currentTarget.select()} |
| 855 | 763 | /> |
| 856 | | {shareablePairingUrl ? ( |
| 764 | {isRevealValueUrl && isRevealValueQrShareable ? ( |
| 857 | 765 | <div className="flex justify-center rounded-xl border border-border/60 bg-muted/30 p-4"> |
| 858 | 766 | <QRCodeSvg |
| 859 | | value={shareablePairingUrl} |
| 767 | value={revealValue} |
| 860 | 768 | size={132} |
| 861 | 769 | level="M" |
| 862 | 770 | marginSize={2} |
| ... | ... | @@ -887,6 +795,91 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ |
| 887 | 795 | </Button> |
| 888 | 796 | </div> |
| 889 | 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 | 883 | </div> |
| 891 | 884 | ); |
| 892 | 885 | }); |