| author | |
| committer | |
| log | c14f6015bfe479d313355cb234af1a5c16dbb15f |
| tree | de94f5fba1002627d60fd94d847fd9d502eb5a75 |
| parent | 0ff87f251dafb32703d5f531e7b248ad0a581ee2 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 563 insertions(+), 229 deletions(-)
apps/web/src/components/GitActionsControl.tsx+175-106| ... | ... | @@ -79,7 +79,16 @@ import { |
| 79 | 79 | } from "~/components/ui/dialog"; |
| 80 | 80 | import { Group, GroupSeparator } from "~/components/ui/group"; |
| 81 | 81 | import { Input } from "~/components/ui/input"; |
| 82 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; | |
| 82 | import { | |
| 83 | Menu, | |
| 84 | MenuItem, | |
| 85 | MenuItemLabel, | |
| 86 | MenuPopup, | |
| 87 | MenuSub, | |
| 88 | MenuSubTrigger, | |
| 89 | MenuSubPopup, | |
| 90 | MenuTrigger, | |
| 91 | } from "~/components/ui/menu"; | |
| 83 | 92 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 84 | 93 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 85 | 94 | import { Textarea } from "~/components/ui/textarea"; |
| ... | ... | @@ -108,6 +117,7 @@ import { useOpenLink } from "~/browser/useOpenLink"; |
| 108 | 117 | import { useOpenPrLink } from "~/lib/openPullRequestLink"; |
| 109 | 118 | |
| 110 | 119 | interface GitActionsControlProps { |
| 120 | presentation?: "toolbar" | "menu"; | |
| 111 | 121 | gitCwd: string | null; |
| 112 | 122 | activeThreadRef: ScopedThreadRef | null; |
| 113 | 123 | draftId?: DraftId; |
| ... | ... | @@ -374,24 +384,25 @@ function GitActionItemIcon({ |
| 374 | 384 | function GitQuickActionIcon({ |
| 375 | 385 | quickAction, |
| 376 | 386 | SourceControlIcon, |
| 387 | className = "size-3.5", | |
| 377 | 388 | }: { |
| 378 | 389 | quickAction: GitQuickAction; |
| 390 | className?: string; | |
| 379 | 391 | SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"]; |
| 380 | 392 | }) { |
| 381 | const iconClassName = "size-3.5"; | |
| 382 | if (quickAction.kind === "open_pr") return <SourceControlIcon className={iconClassName} />; | |
| 383 | if (quickAction.kind === "open_publish") return <CloudUploadIcon className={iconClassName} />; | |
| 384 | if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={iconClassName} />; | |
| 393 | if (quickAction.kind === "open_pr") return <SourceControlIcon className={className} />; | |
| 394 | if (quickAction.kind === "open_publish") return <CloudUploadIcon className={className} />; | |
| 395 | if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={className} />; | |
| 385 | 396 | if (quickAction.kind === "run_action") { |
| 386 | if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />; | |
| 397 | if (quickAction.action === "commit") return <GitCommitIcon className={className} />; | |
| 387 | 398 | if (quickAction.action === "push" || quickAction.action === "commit_push") { |
| 388 | return <CloudUploadIcon className={iconClassName} />; | |
| 399 | return <CloudUploadIcon className={className} />; | |
| 389 | 400 | } |
| 390 | return <SourceControlIcon className={iconClassName} />; | |
| 401 | return <SourceControlIcon className={className} />; | |
| 391 | 402 | } |
| 392 | if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />; | |
| 393 | if (quickAction.label === "Push") return <CloudUploadIcon className={iconClassName} />; | |
| 394 | return <InfoIcon className={iconClassName} />; | |
| 403 | if (quickAction.label === "Commit") return <GitCommitIcon className={className} />; | |
| 404 | if (quickAction.label === "Push") return <CloudUploadIcon className={className} />; | |
| 405 | return <InfoIcon className={className} />; | |
| 395 | 406 | } |
| 396 | 407 | |
| 397 | 408 | interface PublishRepositoryDialogProps { |
| ... | ... | @@ -944,6 +955,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 944 | 955 | } |
| 945 | 956 | |
| 946 | 957 | export default function GitActionsControl({ |
| 958 | presentation = "toolbar", | |
| 947 | 959 | gitCwd, |
| 948 | 960 | activeThreadRef, |
| 949 | 961 | draftId, |
| ... | ... | @@ -1632,33 +1644,161 @@ export default function GitActionsControl({ |
| 1632 | 1644 | |
| 1633 | 1645 | const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; |
| 1634 | 1646 | |
| 1635 | if (!gitCwd) return null; | |
| 1636 | ||
| 1637 | return ( | |
| 1647 | const initializeGit = () => { | |
| 1648 | void (async () => { | |
| 1649 | const result = await initAction.run(); | |
| 1650 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | |
| 1651 | return; | |
| 1652 | } | |
| 1653 | const error = squashAtomCommandFailure(result); | |
| 1654 | toastManager.add( | |
| 1655 | stackedThreadToast({ | |
| 1656 | type: "error", | |
| 1657 | title: "Git initialization failed", | |
| 1658 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 1659 | ...(threadToastData !== undefined ? { data: threadToastData } : {}), | |
| 1660 | }), | |
| 1661 | ); | |
| 1662 | })(); | |
| 1663 | }; | |
| 1664 | const gitItems = ( | |
| 1638 | 1665 | <> |
| 1639 | {!isRepo ? ( | |
| 1640 | <Button | |
| 1641 | variant="outline" | |
| 1642 | size="xs" | |
| 1643 | disabled={initAction.isPending} | |
| 1666 | {gitActionMenuItems.map((item) => { | |
| 1667 | const disabledReason = getMenuActionDisabledReason({ | |
| 1668 | item, | |
| 1669 | gitStatus: gitStatusForActions, | |
| 1670 | isBusy: isGitActionRunning, | |
| 1671 | hasPrimaryRemote, | |
| 1672 | }); | |
| 1673 | if (item.disabled && disabledReason && presentation === "menu") { | |
| 1674 | return ( | |
| 1675 | <div key={`${item.id}-${item.label}`}> | |
| 1676 | <MenuItem density={presentation === "menu" ? "touch" : "default"} disabled> | |
| 1677 | <GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} /> | |
| 1678 | <MenuItemLabel>{item.label}</MenuItemLabel> | |
| 1679 | </MenuItem> | |
| 1680 | <p className="max-w-64 px-2 pb-2 text-xs text-muted-foreground">{disabledReason}</p> | |
| 1681 | </div> | |
| 1682 | ); | |
| 1683 | } | |
| 1684 | if (item.disabled && disabledReason) { | |
| 1685 | return ( | |
| 1686 | <Popover key={`${item.id}-${item.label}`}> | |
| 1687 | <PopoverTrigger | |
| 1688 | openOnHover | |
| 1689 | nativeButton={false} | |
| 1690 | render={<span className="block w-max cursor-not-allowed" />} | |
| 1691 | > | |
| 1692 | <MenuItem | |
| 1693 | density={presentation === "menu" ? "touch" : "default"} | |
| 1694 | className="w-full" | |
| 1695 | disabled | |
| 1696 | > | |
| 1697 | <GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} /> | |
| 1698 | <MenuItemLabel>{item.label}</MenuItemLabel> | |
| 1699 | </MenuItem> | |
| 1700 | </PopoverTrigger> | |
| 1701 | <PopoverPopup tooltipStyle side="left" align="center"> | |
| 1702 | {disabledReason} | |
| 1703 | </PopoverPopup> | |
| 1704 | </Popover> | |
| 1705 | ); | |
| 1706 | } | |
| 1707 | ||
| 1708 | return ( | |
| 1709 | <MenuItem | |
| 1710 | density={presentation === "menu" ? "touch" : "default"} | |
| 1711 | key={`${item.id}-${item.label}`} | |
| 1712 | disabled={item.disabled} | |
| 1713 | onClick={() => { | |
| 1714 | openDialogForMenuItem(item); | |
| 1715 | }} | |
| 1716 | > | |
| 1717 | <GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} /> | |
| 1718 | <MenuItemLabel>{item.label}</MenuItemLabel> | |
| 1719 | </MenuItem> | |
| 1720 | ); | |
| 1721 | })} | |
| 1722 | {canPublishRepository ? ( | |
| 1723 | <MenuItem | |
| 1724 | density={presentation === "menu" ? "touch" : "default"} | |
| 1725 | disabled={isGitActionRunning} | |
| 1644 | 1726 | onClick={() => { |
| 1645 | void (async () => { | |
| 1646 | const result = await initAction.run(); | |
| 1647 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | |
| 1648 | return; | |
| 1649 | } | |
| 1650 | const error = squashAtomCommandFailure(result); | |
| 1651 | toastManager.add( | |
| 1652 | stackedThreadToast({ | |
| 1653 | type: "error", | |
| 1654 | title: "Git initialization failed", | |
| 1655 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 1656 | ...(threadToastData !== undefined ? { data: threadToastData } : {}), | |
| 1657 | }), | |
| 1658 | ); | |
| 1659 | })(); | |
| 1727 | setIsPublishDialogOpen(true); | |
| 1660 | 1728 | }} |
| 1661 | 1729 | > |
| 1730 | <CloudUploadIcon /> | |
| 1731 | <MenuItemLabel>Publish repository...</MenuItemLabel> | |
| 1732 | </MenuItem> | |
| 1733 | ) : null} | |
| 1734 | {gitStatusForActions?.refName === null && ( | |
| 1735 | <p className="px-2 py-1.5 text-xs text-warning"> | |
| 1736 | Detached HEAD: create and check out a branch to enable push and pull request actions. | |
| 1737 | </p> | |
| 1738 | )} | |
| 1739 | {gitStatusForActions && | |
| 1740 | gitStatusForActions.refName !== null && | |
| 1741 | !gitStatusForActions.hasWorkingTreeChanges && | |
| 1742 | gitStatusForActions.behindCount > 0 && | |
| 1743 | gitStatusForActions.aheadCount === 0 && ( | |
| 1744 | <p className="px-2 py-1.5 text-xs text-warning">Behind upstream. Pull/rebase first.</p> | |
| 1745 | )} | |
| 1746 | {gitStatusError && <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError}</p>} | |
| 1747 | </> | |
| 1748 | ); | |
| 1749 | ||
| 1750 | if (!gitCwd) return null; | |
| 1751 | ||
| 1752 | return ( | |
| 1753 | <> | |
| 1754 | {presentation === "menu" ? ( | |
| 1755 | !isRepo ? ( | |
| 1756 | <MenuItem | |
| 1757 | density={presentation === "menu" ? "touch" : "default"} | |
| 1758 | ||
| 1759 | disabled={initAction.isPending} | |
| 1760 | onClick={initializeGit} | |
| 1761 | > | |
| 1762 | <GitBranchPlusIcon className="size-4" /> | |
| 1763 | <MenuItemLabel> | |
| 1764 | {initAction.isPending ? "Initializing..." : "Initialize Git"} | |
| 1765 | </MenuItemLabel> | |
| 1766 | </MenuItem> | |
| 1767 | ) : ( | |
| 1768 | <> | |
| 1769 | <MenuItem | |
| 1770 | density={presentation === "menu" ? "touch" : "default"} | |
| 1771 | ||
| 1772 | disabled={isGitActionRunning || quickAction.disabled || !!quickActionDisabledReason} | |
| 1773 | onClick={runQuickAction} | |
| 1774 | > | |
| 1775 | <GitQuickActionIcon | |
| 1776 | className="size-4" | |
| 1777 | quickAction={quickAction} | |
| 1778 | SourceControlIcon={SourceControlIcon} | |
| 1779 | /> | |
| 1780 | <MenuItemLabel>{quickAction.label}</MenuItemLabel> | |
| 1781 | </MenuItem> | |
| 1782 | {quickActionDisabledReason && ( | |
| 1783 | <p className="max-w-64 px-2 py-1.5 text-xs text-warning"> | |
| 1784 | {quickActionDisabledReason} | |
| 1785 | </p> | |
| 1786 | )} | |
| 1787 | <MenuSub | |
| 1788 | onOpenChange={(open) => { | |
| 1789 | if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); | |
| 1790 | }} | |
| 1791 | > | |
| 1792 | <MenuSubTrigger density="touch" disabled={isGitActionRunning}> | |
| 1793 | <SourceControlIcon className="size-4" /> | |
| 1794 | <MenuItemLabel>Git actions</MenuItemLabel> | |
| 1795 | </MenuSubTrigger> | |
| 1796 | <MenuSubPopup className="min-w-32 max-w-[calc(100vw-2rem)]">{gitItems}</MenuSubPopup> | |
| 1797 | </MenuSub> | |
| 1798 | </> | |
| 1799 | ) | |
| 1800 | ) : !isRepo ? ( | |
| 1801 | <Button variant="outline" size="xs" disabled={initAction.isPending} onClick={initializeGit}> | |
| 1662 | 1802 | <GitBranchPlusIcon className="size-3.5" aria-hidden /> |
| 1663 | 1803 | <span className="ml-0.5"> |
| 1664 | 1804 | {initAction.isPending ? "Initializing..." : "Initialize Git"} |
| ... | ... | @@ -1720,78 +1860,7 @@ export default function GitActionsControl({ |
| 1720 | 1860 | <ChevronDownIcon aria-hidden="true" className="size-4" /> |
| 1721 | 1861 | </MenuTrigger> |
| 1722 | 1862 | <MenuPopup align="end" className="w-full"> |
| 1723 | {gitActionMenuItems.map((item) => { | |
| 1724 | const disabledReason = getMenuActionDisabledReason({ | |
| 1725 | item, | |
| 1726 | gitStatus: gitStatusForActions, | |
| 1727 | isBusy: isGitActionRunning, | |
| 1728 | hasPrimaryRemote, | |
| 1729 | }); | |
| 1730 | if (item.disabled && disabledReason) { | |
| 1731 | return ( | |
| 1732 | <Popover key={`${item.id}-${item.label}`}> | |
| 1733 | <PopoverTrigger | |
| 1734 | openOnHover | |
| 1735 | nativeButton={false} | |
| 1736 | render={<span className="block w-max cursor-not-allowed" />} | |
| 1737 | > | |
| 1738 | <MenuItem className="w-full" disabled> | |
| 1739 | <GitActionItemIcon | |
| 1740 | icon={item.icon} | |
| 1741 | SourceControlIcon={SourceControlIcon} | |
| 1742 | /> | |
| 1743 | {item.label} | |
| 1744 | </MenuItem> | |
| 1745 | </PopoverTrigger> | |
| 1746 | <PopoverPopup tooltipStyle side="left" align="center"> | |
| 1747 | {disabledReason} | |
| 1748 | </PopoverPopup> | |
| 1749 | </Popover> | |
| 1750 | ); | |
| 1751 | } | |
| 1752 | ||
| 1753 | return ( | |
| 1754 | <MenuItem | |
| 1755 | key={`${item.id}-${item.label}`} | |
| 1756 | disabled={item.disabled} | |
| 1757 | onClick={() => { | |
| 1758 | openDialogForMenuItem(item); | |
| 1759 | }} | |
| 1760 | > | |
| 1761 | <GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} /> | |
| 1762 | {item.label} | |
| 1763 | </MenuItem> | |
| 1764 | ); | |
| 1765 | })} | |
| 1766 | {canPublishRepository ? ( | |
| 1767 | <MenuItem | |
| 1768 | disabled={isGitActionRunning} | |
| 1769 | onClick={() => { | |
| 1770 | setIsPublishDialogOpen(true); | |
| 1771 | }} | |
| 1772 | > | |
| 1773 | <CloudUploadIcon /> | |
| 1774 | Publish repository... | |
| 1775 | </MenuItem> | |
| 1776 | ) : null} | |
| 1777 | {gitStatusForActions?.refName === null && ( | |
| 1778 | <p className="px-2 py-1.5 text-xs text-warning"> | |
| 1779 | Detached HEAD: create and check out a branch to enable push and pull request | |
| 1780 | actions. | |
| 1781 | </p> | |
| 1782 | )} | |
| 1783 | {gitStatusForActions && | |
| 1784 | gitStatusForActions.refName !== null && | |
| 1785 | !gitStatusForActions.hasWorkingTreeChanges && | |
| 1786 | gitStatusForActions.behindCount > 0 && | |
| 1787 | gitStatusForActions.aheadCount === 0 && ( | |
| 1788 | <p className="px-2 py-1.5 text-xs text-warning"> | |
| 1789 | Behind upstream. Pull/rebase first. | |
| 1790 | </p> | |
| 1791 | )} | |
| 1792 | {gitStatusError && ( | |
| 1793 | <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError}</p> | |
| 1794 | )} | |
| 1863 | {gitItems} | |
| 1795 | 1864 | </MenuPopup> |
| 1796 | 1865 | </Menu> |
| 1797 | 1866 | </Group> |
apps/web/src/components/ProjectScriptsControl.tsx+127-55| ... | ... | @@ -28,10 +28,14 @@ import { |
| 28 | 28 | MenuGroup, |
| 29 | 29 | MenuGroupLabel, |
| 30 | 30 | MenuItem, |
| 31 | MenuItemLabel, | |
| 31 | 32 | MenuPopup, |
| 32 | 33 | MenuSeparator, |
| 33 | 34 | MenuShortcut, |
| 34 | 35 | MenuTrigger, |
| 36 | MenuSub, | |
| 37 | MenuSubTrigger, | |
| 38 | MenuSubPopup, | |
| 35 | 39 | } from "./ui/menu"; |
| 36 | 40 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 37 | 41 | |
| ... | ... | @@ -40,6 +44,8 @@ export type { NewProjectScriptInput, ProjectScriptActionResult }; |
| 40 | 44 | const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = []; |
| 41 | 45 | |
| 42 | 46 | interface ProjectScriptsControlProps { |
| 47 | presentation?: "toolbar" | "menu"; | |
| 48 | onRequestMenuClose?: () => void; | |
| 43 | 49 | scripts: ReadonlyArray<ProjectScript>; |
| 44 | 50 | /** Scripts declared in the project's checked-in t3.json, offered for import. */ |
| 45 | 51 | fileScripts?: ReadonlyArray<T3ProjectFileScript>; |
| ... | ... | @@ -55,6 +61,8 @@ interface ProjectScriptsControlProps { |
| 55 | 61 | } |
| 56 | 62 | |
| 57 | 63 | export default function ProjectScriptsControl({ |
| 64 | presentation = "toolbar", | |
| 65 | onRequestMenuClose, | |
| 58 | 66 | scripts, |
| 59 | 67 | fileScripts = NO_FILE_SCRIPTS, |
| 60 | 68 | keybindings, |
| ... | ... | @@ -65,9 +73,13 @@ export default function ProjectScriptsControl({ |
| 65 | 73 | onDeleteScript, |
| 66 | 74 | }: ProjectScriptsControlProps) { |
| 67 | 75 | const [actionsMenuOpen, setActionsMenuOpen] = useState({ |
| 76 | presentation, | |
| 68 | 77 | scripts: false, |
| 69 | 78 | imports: false, |
| 70 | 79 | }); |
| 80 | if (actionsMenuOpen.presentation !== presentation) { | |
| 81 | setActionsMenuOpen({ presentation, scripts: false, imports: false }); | |
| 82 | } | |
| 71 | 83 | const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null); |
| 72 | 84 | |
| 73 | 85 | const primaryScript = useMemo(() => { |
| ... | ... | @@ -97,7 +109,8 @@ export default function ProjectScriptsControl({ |
| 97 | 109 | }; |
| 98 | 110 | |
| 99 | 111 | const openEditDialog = (script: ProjectScript) => { |
| 100 | setActionsMenuOpen({ scripts: false, imports: false }); | |
| 112 | onRequestMenuClose?.(); | |
| 113 | setActionsMenuOpen({ presentation, scripts: false, imports: false }); | |
| 101 | 114 | setEditorRequest(editorRequestForScript(script, keybindings)); |
| 102 | 115 | }; |
| 103 | 116 | |
| ... | ... | @@ -138,12 +151,13 @@ export default function ProjectScriptsControl({ |
| 138 | 151 | <MenuGroupLabel>From t3.json</MenuGroupLabel> |
| 139 | 152 | {importableScripts.map((fileScript) => ( |
| 140 | 153 | <MenuItem |
| 154 | density={presentation === "menu" ? "touch" : "default"} | |
| 141 | 155 | key={`${fileScript.name} ${fileScript.command}`} |
| 142 | 156 | className={dropdownItemClassName} |
| 143 | 157 | onClick={() => void importFileScript(fileScript)} |
| 144 | 158 | > |
| 145 | 159 | <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4" /> |
| 146 | <span className="truncate">{fileScript.name}</span> | |
| 160 | <MenuItemLabel className="truncate">{fileScript.name}</MenuItemLabel> | |
| 147 | 161 | <MenuShortcut className="ms-auto"> |
| 148 | 162 | <DownloadIcon className="size-3.5" aria-label="Import" /> |
| 149 | 163 | </MenuShortcut> |
| ... | ... | @@ -153,9 +167,112 @@ export default function ProjectScriptsControl({ |
| 153 | 167 | </> |
| 154 | 168 | ); |
| 155 | 169 | |
| 170 | const scriptItems = ( | |
| 171 | <> | |
| 172 | {scripts.map((script) => { | |
| 173 | const shortcutLabel = shortcutLabelForCommand( | |
| 174 | keybindings, | |
| 175 | commandForProjectScript(script.id), | |
| 176 | ); | |
| 177 | return ( | |
| 178 | <MenuItem | |
| 179 | density={presentation === "menu" ? "touch" : "default"} | |
| 180 | key={script.id} | |
| 181 | className={`group ${dropdownItemClassName}`} | |
| 182 | onClick={() => onRunScript(script)} | |
| 183 | > | |
| 184 | <ScriptIcon icon={script.icon} className="size-4" /> | |
| 185 | <MenuItemLabel className="truncate"> | |
| 186 | {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name} | |
| 187 | </MenuItemLabel> | |
| 188 | <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end"> | |
| 189 | {shortcutLabel && ( | |
| 190 | <MenuShortcut | |
| 191 | className={ | |
| 192 | presentation === "menu" | |
| 193 | ? "ms-0 mr-7" | |
| 194 | : "ms-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0" | |
| 195 | } | |
| 196 | > | |
| 197 | {shortcutLabel} | |
| 198 | </MenuShortcut> | |
| 199 | )} | |
| 200 | <Button | |
| 201 | type="button" | |
| 202 | variant="ghost" | |
| 203 | size="icon-xs" | |
| 204 | className={`absolute right-0 top-1/2 size-6 -translate-y-1/2 ${presentation === "menu" ? "" : "opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-visible:opacity-100 group-focus-visible:pointer-events-auto"}`} | |
| 205 | aria-label={`Edit ${script.name}`} | |
| 206 | onPointerDown={(event) => { | |
| 207 | event.preventDefault(); | |
| 208 | event.stopPropagation(); | |
| 209 | }} | |
| 210 | onClick={(event) => { | |
| 211 | event.preventDefault(); | |
| 212 | event.stopPropagation(); | |
| 213 | openEditDialog(script); | |
| 214 | }} | |
| 215 | > | |
| 216 | <SettingsIcon className="size-3.5" /> | |
| 217 | </Button> | |
| 218 | </span> | |
| 219 | </MenuItem> | |
| 220 | ); | |
| 221 | })} | |
| 222 | {importMenuItems} | |
| 223 | <MenuItem | |
| 224 | density={presentation === "menu" ? "touch" : "default"} | |
| 225 | className={dropdownItemClassName} | |
| 226 | onClick={openAddDialog} | |
| 227 | > | |
| 228 | <PlusIcon className="size-4" /> | |
| 229 | <MenuItemLabel>Add action</MenuItemLabel> | |
| 230 | </MenuItem> | |
| 231 | </> | |
| 232 | ); | |
| 233 | ||
| 156 | 234 | return ( |
| 157 | 235 | <> |
| 158 | {primaryScript ? ( | |
| 236 | {presentation === "menu" ? ( | |
| 237 | <> | |
| 238 | {primaryScript && ( | |
| 239 | <MenuItem | |
| 240 | density={presentation === "menu" ? "touch" : "default"} | |
| 241 | onClick={() => onRunScript(primaryScript)} | |
| 242 | > | |
| 243 | <ScriptIcon icon={primaryScript.icon} className="size-4" /> | |
| 244 | <MenuItemLabel className="truncate">Run {primaryScript.name}</MenuItemLabel> | |
| 245 | <MenuShortcut> | |
| 246 | {shortcutLabelForCommand(keybindings, commandForProjectScript(primaryScript.id))} | |
| 247 | </MenuShortcut> | |
| 248 | </MenuItem> | |
| 249 | )} | |
| 250 | {primaryScript || importableScripts.length > 0 ? ( | |
| 251 | <MenuSub | |
| 252 | open={actionsMenuOpen.scripts} | |
| 253 | onOpenChange={(open) => | |
| 254 | setActionsMenuOpen({ presentation, scripts: open, imports: false }) | |
| 255 | } | |
| 256 | > | |
| 257 | <MenuSubTrigger density="touch"> | |
| 258 | <ScriptIcon icon="play" className="size-4" /> | |
| 259 | <MenuItemLabel>Project actions</MenuItemLabel> | |
| 260 | </MenuSubTrigger> | |
| 261 | <MenuSubPopup className="min-w-32 max-w-[calc(100vw-2rem)]"> | |
| 262 | {scriptItems} | |
| 263 | </MenuSubPopup> | |
| 264 | </MenuSub> | |
| 265 | ) : ( | |
| 266 | <MenuItem | |
| 267 | density={presentation === "menu" ? "touch" : "default"} | |
| 268 | onClick={openAddDialog} | |
| 269 | > | |
| 270 | <PlusIcon className="size-4" /> | |
| 271 | <MenuItemLabel>Add project action…</MenuItemLabel> | |
| 272 | </MenuItem> | |
| 273 | )} | |
| 274 | </> | |
| 275 | ) : primaryScript ? ( | |
| 159 | 276 | <Group aria-label="Project scripts"> |
| 160 | 277 | <Tooltip> |
| 161 | 278 | <TooltipTrigger |
| ... | ... | @@ -183,70 +300,25 @@ export default function ProjectScriptsControl({ |
| 183 | 300 | <Menu |
| 184 | 301 | highlightItemOnHover={false} |
| 185 | 302 | open={actionsMenuOpen.scripts} |
| 186 | onOpenChange={(open) => setActionsMenuOpen({ scripts: open, imports: false })} | |
| 303 | onOpenChange={(open) => | |
| 304 | setActionsMenuOpen({ presentation, scripts: open, imports: false }) | |
| 305 | } | |
| 187 | 306 | > |
| 188 | 307 | <MenuTrigger |
| 189 | 308 | render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />} |
| 190 | 309 | > |
| 191 | 310 | <ChevronDownIcon className="size-4" /> |
| 192 | 311 | </MenuTrigger> |
| 193 | <MenuPopup align="end"> | |
| 194 | {scripts.map((script) => { | |
| 195 | const shortcutLabel = shortcutLabelForCommand( | |
| 196 | keybindings, | |
| 197 | commandForProjectScript(script.id), | |
| 198 | ); | |
| 199 | return ( | |
| 200 | <MenuItem | |
| 201 | key={script.id} | |
| 202 | className={`group ${dropdownItemClassName}`} | |
| 203 | onClick={() => onRunScript(script)} | |
| 204 | > | |
| 205 | <ScriptIcon icon={script.icon} className="size-4" /> | |
| 206 | <span className="truncate"> | |
| 207 | {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name} | |
| 208 | </span> | |
| 209 | <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end"> | |
| 210 | {shortcutLabel && ( | |
| 211 | <MenuShortcut className="ms-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"> | |
| 212 | {shortcutLabel} | |
| 213 | </MenuShortcut> | |
| 214 | )} | |
| 215 | <Button | |
| 216 | type="button" | |
| 217 | variant="ghost" | |
| 218 | size="icon-xs" | |
| 219 | className="absolute right-0 top-1/2 size-6 -translate-y-1/2 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-visible:opacity-100 group-focus-visible:pointer-events-auto" | |
| 220 | aria-label={`Edit ${script.name}`} | |
| 221 | onPointerDown={(event) => { | |
| 222 | event.preventDefault(); | |
| 223 | event.stopPropagation(); | |
| 224 | }} | |
| 225 | onClick={(event) => { | |
| 226 | event.preventDefault(); | |
| 227 | event.stopPropagation(); | |
| 228 | openEditDialog(script); | |
| 229 | }} | |
| 230 | > | |
| 231 | <SettingsIcon className="size-3.5" /> | |
| 232 | </Button> | |
| 233 | </span> | |
| 234 | </MenuItem> | |
| 235 | ); | |
| 236 | })} | |
| 237 | {importMenuItems} | |
| 238 | <MenuItem className={dropdownItemClassName} onClick={openAddDialog}> | |
| 239 | <PlusIcon className="size-4" /> | |
| 240 | Add action | |
| 241 | </MenuItem> | |
| 242 | </MenuPopup> | |
| 312 | <MenuPopup align="end">{scriptItems}</MenuPopup> | |
| 243 | 313 | </Menu> |
| 244 | 314 | </Group> |
| 245 | 315 | ) : importableScripts.length > 0 ? ( |
| 246 | 316 | <Menu |
| 247 | 317 | highlightItemOnHover={false} |
| 248 | 318 | open={actionsMenuOpen.imports} |
| 249 | onOpenChange={(open) => setActionsMenuOpen({ scripts: false, imports: open })} | |
| 319 | onOpenChange={(open) => | |
| 320 | setActionsMenuOpen({ presentation, scripts: false, imports: open }) | |
| 321 | } | |
| 250 | 322 | > |
| 251 | 323 | <MenuTrigger render={<Button size="xs" variant="outline" aria-label="Project actions" />}> |
| 252 | 324 | <PlusIcon className="size-3.5" /> |
apps/web/src/components/WorkspaceBreadcrumb.tsx+26-3| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 1 | import type { ComponentProps, ReactNode } from "react"; | |
| 2 | 2 | |
| 3 | 3 | import { cn } from "../lib/utils"; |
| 4 | 4 | |
| ... | ... | @@ -26,6 +26,23 @@ interface WorkspaceBreadcrumbItemProps { |
| 26 | 26 | readonly current?: boolean; |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | export function WorkspaceBreadcrumbText({ children, className, ...props }: ComponentProps<"span">) { | |
| 30 | return ( | |
| 31 | <span | |
| 32 | data-slot="workspace-breadcrumb-text" | |
| 33 | // Center the capital letters with adjacent icons, not the font's leading. | |
| 34 | // Padding preserves accents and descenders when a label is truncated. | |
| 35 | className={cn( | |
| 36 | "block min-w-0 truncate [text-box:trim-both_cap_alphabetic] supports-[text-box:trim-both_cap_alphabetic]:py-[0.5em]", | |
| 37 | className, | |
| 38 | )} | |
| 39 | {...props} | |
| 40 | > | |
| 41 | {children} | |
| 42 | </span> | |
| 43 | ); | |
| 44 | } | |
| 45 | ||
| 29 | 46 | export function WorkspaceBreadcrumbItem({ |
| 30 | 47 | children, |
| 31 | 48 | className, |
| ... | ... | @@ -45,10 +62,16 @@ export function WorkspaceBreadcrumbItem({ |
| 45 | 62 | ); |
| 46 | 63 | } |
| 47 | 64 | |
| 48 | export function WorkspaceBreadcrumbSeparator({ className }: { readonly className?: string }) { | |
| 65 | export function WorkspaceBreadcrumbSeparator({ | |
| 66 | className, | |
| 67 | children = "/", | |
| 68 | }: { | |
| 69 | readonly className?: string; | |
| 70 | readonly children?: ReactNode; | |
| 71 | }) { | |
| 49 | 72 | return ( |
| 50 | 73 | <li aria-hidden="true" className={cn("flex shrink-0 items-center text-icon-muted", className)}> |
| 51 | / | |
| 74 | {children} | |
| 52 | 75 | </li> |
| 53 | 76 | ); |
| 54 | 77 | } |
apps/web/src/components/chat/ChatHeader.tsx+125-39| ... | ... | @@ -11,7 +11,7 @@ import { |
| 11 | 11 | isAtomCommandInterrupted, |
| 12 | 12 | squashAtomCommandFailure, |
| 13 | 13 | } from "@t3tools/client-runtime/state/runtime"; |
| 14 | import { ChevronDownIcon } from "lucide-react"; | |
| 14 | import { ChevronDownIcon, EllipsisIcon } from "lucide-react"; | |
| 15 | 15 | import { |
| 16 | 16 | memo, |
| 17 | 17 | useCallback, |
| ... | ... | @@ -22,6 +22,7 @@ import { |
| 22 | 22 | type KeyboardEvent as ReactKeyboardEvent, |
| 23 | 23 | type MouseEvent as ReactMouseEvent, |
| 24 | 24 | } from "react"; |
| 25 | import { createPortal } from "react-dom"; | |
| 25 | 26 | import GitActionsControl from "../GitActionsControl"; |
| 26 | 27 | import { isTrailingDoubleClick } from "../Sidebar.logic"; |
| 27 | 28 | import { type DraftId } from "~/composerDraftStore"; |
| ... | ... | @@ -45,8 +46,12 @@ import { |
| 45 | 46 | WorkspaceBreadcrumb, |
| 46 | 47 | WorkspaceBreadcrumbItem, |
| 47 | 48 | WorkspaceBreadcrumbSeparator, |
| 49 | WorkspaceBreadcrumbText, | |
| 48 | 50 | } from "../WorkspaceBreadcrumb"; |
| 49 | 51 | import { cn } from "~/lib/utils"; |
| 52 | import { useIsMobile } from "~/hooks/useMediaQuery"; | |
| 53 | import { Button } from "../ui/button"; | |
| 54 | import { Menu, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; | |
| 50 | 55 | |
| 51 | 56 | interface ChatHeaderProps { |
| 52 | 57 | activeThreadEnvironmentId: EnvironmentId; |
| ... | ... | @@ -155,6 +160,40 @@ export const ChatHeader = memo(function ChatHeader({ |
| 155 | 160 | breakpoint: { value: HEADER_ACTIONS_EXPANDED_BREAKPOINT_REM, unit: "rem" }, |
| 156 | 161 | }); |
| 157 | 162 | }, [panelAnimationDurationMs, panelAnimationsActive]); |
| 163 | const isMobile = useIsMobile(); | |
| 164 | // Side panels can leave a desktop header narrower than a phone. | |
| 165 | const [isNarrowHeader, setIsNarrowHeader] = useState(false); | |
| 166 | useEffect(() => { | |
| 167 | const container = headerActionsRef.current?.parentElement; | |
| 168 | if (!container) return; | |
| 169 | const update = () => setIsNarrowHeader(container.clientWidth < 512); | |
| 170 | update(); | |
| 171 | const observer = new ResizeObserver(update); | |
| 172 | observer.observe(container); | |
| 173 | return () => observer.disconnect(); | |
| 174 | }, []); | |
| 175 | const actionsCollapsed = isMobile || isNarrowHeader; | |
| 176 | const [actionsOpen, setActionsOpen] = useState(false); | |
| 177 | const [actionsContainer] = useState(() => { | |
| 178 | const container = document.createElement("div"); | |
| 179 | container.className = "contents"; | |
| 180 | return container; | |
| 181 | }); | |
| 182 | // Reparent the DOM host, not the React controls: rotating a phone or resizing | |
| 183 | // a window must not discard an unsaved script or Git dialog. | |
| 184 | const mountInlineActions = useCallback( | |
| 185 | (node: HTMLDivElement | null) => { | |
| 186 | if (node && !actionsCollapsed) node.appendChild(actionsContainer); | |
| 187 | }, | |
| 188 | [actionsContainer, actionsCollapsed], | |
| 189 | ); | |
| 190 | const mountMenuActions = useCallback( | |
| 191 | (node: HTMLDivElement | null) => { | |
| 192 | if (node && actionsCollapsed) node.appendChild(actionsContainer); | |
| 193 | }, | |
| 194 | [actionsContainer, actionsCollapsed], | |
| 195 | ); | |
| 196 | if (!actionsCollapsed && actionsOpen) setActionsOpen(false); | |
| 158 | 197 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 159 | 198 | const activeProjectName = activeProject?.title; |
| 160 | 199 | const activeProjectCwd = activeProject?.workspaceRoot ?? null; |
| ... | ... | @@ -312,6 +351,50 @@ export const ChatHeader = memo(function ChatHeader({ |
| 312 | 351 | }, |
| 313 | 352 | [commitRename], |
| 314 | 353 | ); |
| 354 | const headerActions = ( | |
| 355 | <> | |
| 356 | {activeProjectScripts && ( | |
| 357 | <> | |
| 358 | <ProjectScriptsControl | |
| 359 | onRequestMenuClose={() => setActionsOpen(false)} | |
| 360 | presentation={actionsCollapsed ? "menu" : "toolbar"} | |
| 361 | scripts={activeProjectScripts} | |
| 362 | fileScripts={fileScripts} | |
| 363 | keybindings={keybindings} | |
| 364 | preferredScriptId={preferredScriptId} | |
| 365 | onRunScript={onRunProjectScript} | |
| 366 | onAddScript={onAddProjectScript} | |
| 367 | onUpdateScript={onUpdateProjectScript} | |
| 368 | onDeleteScript={onDeleteProjectScript} | |
| 369 | /> | |
| 370 | </> | |
| 371 | )} | |
| 372 | {showOpenInPicker && ( | |
| 373 | <> | |
| 374 | {actionsCollapsed && activeProjectScripts && <MenuSeparator />} | |
| 375 | <OpenInPicker | |
| 376 | presentation={actionsCollapsed ? "menu" : "toolbar"} | |
| 377 | environmentId={activeThreadEnvironmentId} | |
| 378 | keybindings={keybindings} | |
| 379 | availableEditors={availableEditors} | |
| 380 | openInCwd={openInCwd} | |
| 381 | /> | |
| 382 | </> | |
| 383 | )} | |
| 384 | {activeProjectName && gitCwd && ( | |
| 385 | <> | |
| 386 | {actionsCollapsed && (activeProjectScripts || showOpenInPicker) && <MenuSeparator />} | |
| 387 | <GitActionsControl | |
| 388 | presentation={actionsCollapsed ? "menu" : "toolbar"} | |
| 389 | gitCwd={gitCwd} | |
| 390 | activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)} | |
| 391 | onOpenPullRequest={onOpenPullRequest} | |
| 392 | {...(draftId ? { draftId } : {})} | |
| 393 | /> | |
| 394 | </> | |
| 395 | )} | |
| 396 | </> | |
| 397 | ); | |
| 315 | 398 | return ( |
| 316 | 399 | <div |
| 317 | 400 | className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" |
| ... | ... | @@ -339,12 +422,16 @@ export const ChatHeader = memo(function ChatHeader({ |
| 339 | 422 | } |
| 340 | 423 | > |
| 341 | 424 | <ProjectFavicon project={activeProject} className="size-3.5" /> |
| 342 | <span className="max-w-40 truncate">{activeProjectName}</span> | |
| 425 | <WorkspaceBreadcrumbText className="max-w-40"> | |
| 426 | {activeProjectName} | |
| 427 | </WorkspaceBreadcrumbText> | |
| 343 | 428 | </TooltipTrigger> |
| 344 | 429 | <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup> |
| 345 | 430 | </Tooltip> |
| 346 | 431 | </WorkspaceBreadcrumbItem> |
| 347 | <WorkspaceBreadcrumbSeparator /> | |
| 432 | <WorkspaceBreadcrumbSeparator> | |
| 433 | <WorkspaceBreadcrumbText>/</WorkspaceBreadcrumbText> | |
| 434 | </WorkspaceBreadcrumbSeparator> | |
| 348 | 435 | </> |
| 349 | 436 | ) : null} |
| 350 | 437 | <WorkspaceBreadcrumbItem current className="min-w-10 flex-1"> |
| ... | ... | @@ -377,7 +464,9 @@ export const ChatHeader = memo(function ChatHeader({ |
| 377 | 464 | /> |
| 378 | 465 | } |
| 379 | 466 | > |
| 380 | <h2 className="min-w-0 truncate">{activeThreadTitle}</h2> | |
| 467 | <h2 className="min-w-0"> | |
| 468 | <WorkspaceBreadcrumbText>{activeThreadTitle}</WorkspaceBreadcrumbText> | |
| 469 | </h2> | |
| 381 | 470 | <ChevronDownIcon |
| 382 | 471 | aria-hidden |
| 383 | 472 | data-thread-title-chevron |
| ... | ... | @@ -389,12 +478,10 @@ export const ChatHeader = memo(function ChatHeader({ |
| 389 | 478 | ) : ( |
| 390 | 479 | <Tooltip> |
| 391 | 480 | <TooltipTrigger |
| 392 | render={ | |
| 393 | <h2 aria-label={activeThreadTitle} className="min-w-0 flex-1 truncate"> | |
| 394 | {activeThreadTitle} | |
| 395 | </h2> | |
| 396 | } | |
| 397 | /> | |
| 481 | render={<h2 aria-label={activeThreadTitle} className="min-w-0 flex-1" />} | |
| 482 | > | |
| 483 | <WorkspaceBreadcrumbText>{activeThreadTitle}</WorkspaceBreadcrumbText> | |
| 484 | </TooltipTrigger> | |
| 398 | 485 | <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup> |
| 399 | 486 | </Tooltip> |
| 400 | 487 | )} |
| ... | ... | @@ -405,38 +492,37 @@ export const ChatHeader = memo(function ChatHeader({ |
| 405 | 492 | data-chat-header-actions |
| 406 | 493 | className={cn( |
| 407 | 494 | "flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3", |
| 408 | rightPanelOpen ? "pr-0" : "pr-16", | |
| 495 | // Reserve two panel toggles plus their 4px gaps and 1px edge inset. | |
| 496 | // The page header adds 8px more right padding at sm. | |
| 497 | rightPanelOpen ? "pr-0" : "pr-[calc(--spacing(18)+1px)] sm:pr-[calc(--spacing(14)+1px)]", | |
| 409 | 498 | "[[data-panel-animations=true]_&]:motion-safe:transition-[padding-right] [[data-panel-animations=true]_&]:motion-safe:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:motion-safe:ease-out", |
| 410 | 499 | )} |
| 411 | 500 | > |
| 412 | {activeProjectScripts && ( | |
| 413 | <ProjectScriptsControl | |
| 414 | scripts={activeProjectScripts} | |
| 415 | fileScripts={fileScripts} | |
| 416 | keybindings={keybindings} | |
| 417 | preferredScriptId={preferredScriptId} | |
| 418 | onRunScript={onRunProjectScript} | |
| 419 | onAddScript={onAddProjectScript} | |
| 420 | onUpdateScript={onUpdateProjectScript} | |
| 421 | onDeleteScript={onDeleteProjectScript} | |
| 422 | /> | |
| 423 | )} | |
| 424 | {showOpenInPicker && ( | |
| 425 | <OpenInPicker | |
| 426 | environmentId={activeThreadEnvironmentId} | |
| 427 | keybindings={keybindings} | |
| 428 | availableEditors={availableEditors} | |
| 429 | openInCwd={openInCwd} | |
| 430 | /> | |
| 431 | )} | |
| 432 | {activeProjectName && ( | |
| 433 | <GitActionsControl | |
| 434 | gitCwd={gitCwd} | |
| 435 | activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)} | |
| 436 | onOpenPullRequest={onOpenPullRequest} | |
| 437 | {...(draftId ? { draftId } : {})} | |
| 438 | /> | |
| 439 | )} | |
| 501 | <Menu open={actionsCollapsed && actionsOpen} onOpenChange={setActionsOpen}> | |
| 502 | <MenuTrigger | |
| 503 | className={ | |
| 504 | actionsCollapsed && | |
| 505 | (activeProjectScripts || showOpenInPicker || (activeProjectName && gitCwd)) | |
| 506 | ? undefined | |
| 507 | : "hidden" | |
| 508 | } | |
| 509 | render={<Button size="icon-sm" variant="ghost" aria-label="More header actions" />} | |
| 510 | > | |
| 511 | <EllipsisIcon className="size-4" /> | |
| 512 | </MenuTrigger> | |
| 513 | <div ref={mountInlineActions} className="contents" /> | |
| 514 | <MenuPopup | |
| 515 | data-chat-header-actions | |
| 516 | keepMounted | |
| 517 | aria-label="Header actions" | |
| 518 | align="end" | |
| 519 | className="min-w-56 max-w-[calc(100vw-2rem)]" | |
| 520 | finalFocus={actionsCollapsed ? undefined : false} | |
| 521 | > | |
| 522 | <div ref={mountMenuActions} className="contents" /> | |
| 523 | {createPortal(headerActions, actionsContainer)} | |
| 524 | </MenuPopup> | |
| 525 | </Menu> | |
| 440 | 526 | </div> |
| 441 | 527 | </div> |
| 442 | 528 | ); |
apps/web/src/components/chat/OpenInPicker.tsx+78-23| ... | ... | @@ -15,10 +15,20 @@ import { |
| 15 | 15 | useRemoteOpenState, |
| 16 | 16 | } from "../../remoteOpen"; |
| 17 | 17 | import { useEnvironment } from "../../state/environments"; |
| 18 | import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; | |
| 18 | import { ChevronDownIcon, FolderClosedIcon, SquareArrowOutUpRightIcon } from "lucide-react"; | |
| 19 | 19 | import { Button } from "../ui/button"; |
| 20 | 20 | import { Group, GroupSeparator } from "../ui/group"; |
| 21 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; | |
| 21 | import { | |
| 22 | Menu, | |
| 23 | MenuItem, | |
| 24 | MenuItemLabel, | |
| 25 | MenuPopup, | |
| 26 | MenuShortcut, | |
| 27 | MenuSub, | |
| 28 | MenuSubTrigger, | |
| 29 | MenuSubPopup, | |
| 30 | MenuTrigger, | |
| 31 | } from "../ui/menu"; | |
| 22 | 32 | import { |
| 23 | 33 | AntigravityIcon, |
| 24 | 34 | CursorIcon, |
| ... | ... | @@ -187,6 +197,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 187 | 197 | keybindings, |
| 188 | 198 | availableEditors, |
| 189 | 199 | openInCwd, |
| 200 | presentation = "toolbar", | |
| 190 | 201 | compact = false, |
| 191 | 202 | enableShortcut = true, |
| 192 | 203 | }: { |
| ... | ... | @@ -194,6 +205,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 194 | 205 | keybindings: ResolvedKeybindingsConfig; |
| 195 | 206 | availableEditors: ReadonlyArray<EditorId>; |
| 196 | 207 | openInCwd: string | null; |
| 208 | presentation?: "toolbar" | "menu"; | |
| 197 | 209 | compact?: boolean; |
| 198 | 210 | enableShortcut?: boolean; |
| 199 | 211 | }) { |
| ... | ... | @@ -274,6 +286,69 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 274 | 286 | return () => window.removeEventListener("keydown", handler); |
| 275 | 287 | }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]); |
| 276 | 288 | |
| 289 | const editorItems = ( | |
| 290 | <> | |
| 291 | {remote.mode === "remote-unavailable" ? ( | |
| 292 | <MenuItem density={presentation === "menu" ? "touch" : "default"} disabled> | |
| 293 | No SSH route to {environmentLabel} | |
| 294 | </MenuItem> | |
| 295 | ) : ( | |
| 296 | <> | |
| 297 | {options.length === 0 && ( | |
| 298 | <MenuItem density={presentation === "menu" ? "touch" : "default"} disabled> | |
| 299 | No installed editors found | |
| 300 | </MenuItem> | |
| 301 | )} | |
| 302 | {options.map(({ label, Icon, value, kind }) => ( | |
| 303 | <MenuItem | |
| 304 | density={presentation === "menu" ? "touch" : "default"} | |
| 305 | key={value} | |
| 306 | onClick={() => openInEditor(value)} | |
| 307 | > | |
| 308 | <Icon aria-hidden="true" className={getOpenInIconClass(kind)} /> | |
| 309 | <MenuItemLabel>{label}</MenuItemLabel> | |
| 310 | {value === preferredEditor && openFavoriteEditorShortcutLabel && ( | |
| 311 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> | |
| 312 | )} | |
| 313 | </MenuItem> | |
| 314 | ))} | |
| 315 | {remote.mode === "remote-links" && !remoteHintSeen && ( | |
| 316 | <MenuItem density={presentation === "menu" ? "touch" : "default"} disabled> | |
| 317 | Opens over SSH. Needs your key on {environmentLabel} | |
| 318 | </MenuItem> | |
| 319 | )} | |
| 320 | </> | |
| 321 | )} | |
| 322 | </> | |
| 323 | ); | |
| 324 | if (presentation === "menu") { | |
| 325 | return ( | |
| 326 | <> | |
| 327 | {primaryOption && ( | |
| 328 | <MenuItem | |
| 329 | density={presentation === "menu" ? "touch" : "default"} | |
| 330 | ||
| 331 | disabled={!openInCwd || remote.mode === "remote-unavailable"} | |
| 332 | onClick={() => openInEditor(preferredEditor)} | |
| 333 | > | |
| 334 | <primaryOption.Icon className={cn("size-4", getOpenInIconClass(primaryOption.kind))} /> | |
| 335 | <MenuItemLabel className="truncate">Open in {primaryOption.label}</MenuItemLabel> | |
| 336 | {openFavoriteEditorShortcutLabel && ( | |
| 337 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> | |
| 338 | )} | |
| 339 | </MenuItem> | |
| 340 | )} | |
| 341 | <MenuSub> | |
| 342 | <MenuSubTrigger density="touch"> | |
| 343 | <SquareArrowOutUpRightIcon className="size-4" /> | |
| 344 | <MenuItemLabel>Open in…</MenuItemLabel> | |
| 345 | </MenuSubTrigger> | |
| 346 | <MenuSubPopup className="min-w-32 max-w-[calc(100vw-2rem)]">{editorItems}</MenuSubPopup> | |
| 347 | </MenuSub> | |
| 348 | </> | |
| 349 | ); | |
| 350 | } | |
| 351 | ||
| 277 | 352 | return ( |
| 278 | 353 | <Group aria-label="Open in editor"> |
| 279 | 354 | <Button |
| ... | ... | @@ -307,27 +382,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 307 | 382 | > |
| 308 | 383 | <ChevronDownIcon aria-hidden="true" className="size-4" /> |
| 309 | 384 | </MenuTrigger> |
| 310 | <MenuPopup align="end"> | |
| 311 | {remote.mode === "remote-unavailable" ? ( | |
| 312 | <MenuItem disabled>No SSH route to {environmentLabel}</MenuItem> | |
| 313 | ) : ( | |
| 314 | <> | |
| 315 | {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>} | |
| 316 | {options.map(({ label, Icon, value, kind }) => ( | |
| 317 | <MenuItem key={value} onClick={() => openInEditor(value)}> | |
| 318 | <Icon aria-hidden="true" className={getOpenInIconClass(kind)} /> | |
| 319 | {label} | |
| 320 | {value === preferredEditor && openFavoriteEditorShortcutLabel && ( | |
| 321 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> | |
| 322 | )} | |
| 323 | </MenuItem> | |
| 324 | ))} | |
| 325 | {remote.mode === "remote-links" && !remoteHintSeen && ( | |
| 326 | <MenuItem disabled>Opens over SSH. Needs your key on {environmentLabel}</MenuItem> | |
| 327 | )} | |
| 328 | </> | |
| 329 | )} | |
| 330 | </MenuPopup> | |
| 385 | <MenuPopup align="end">{editorItems}</MenuPopup> | |
| 331 | 386 | </Menu> |
| 332 | 387 | </Group> |
| 333 | 388 | ); |
apps/web/src/components/ui/menu.tsx+27-1| ... | ... | @@ -25,6 +25,7 @@ function MenuPopup({ |
| 25 | 25 | alignOffset, |
| 26 | 26 | side = "bottom", |
| 27 | 27 | anchor, |
| 28 | keepMounted = false, | |
| 28 | 29 | ...props |
| 29 | 30 | }: MenuPrimitive.Popup.Props & { |
| 30 | 31 | align?: MenuPrimitive.Positioner.Props["align"]; |
| ... | ... | @@ -32,6 +33,7 @@ function MenuPopup({ |
| 32 | 33 | alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"]; |
| 33 | 34 | side?: MenuPrimitive.Positioner.Props["side"]; |
| 34 | 35 | anchor?: MenuPrimitive.Positioner.Props["anchor"]; |
| 36 | keepMounted?: boolean; | |
| 35 | 37 | }) { |
| 36 | 38 | const hasExplicitWidthClass = |
| 37 | 39 | typeof className === "string" && |
| ... | ... | @@ -41,7 +43,7 @@ function MenuPopup({ |
| 41 | 43 | }); |
| 42 | 44 | |
| 43 | 45 | return ( |
| 44 | <MenuPrimitive.Portal> | |
| 46 | <MenuPrimitive.Portal keepMounted={keepMounted}> | |
| 45 | 47 | <MenuPrimitive.Positioner |
| 46 | 48 | align={align} |
| 47 | 49 | alignOffset={alignOffset} |
| ... | ... | @@ -74,10 +76,12 @@ function MenuGroup(props: MenuPrimitive.Group.Props) { |
| 74 | 76 | function MenuItem({ |
| 75 | 77 | className, |
| 76 | 78 | inset, |
| 79 | density = "default", | |
| 77 | 80 | variant = "default", |
| 78 | 81 | ...props |
| 79 | 82 | }: MenuPrimitive.Item.Props & { |
| 80 | 83 | inset?: boolean; |
| 84 | density?: "default" | "touch"; | |
| 81 | 85 | variant?: "default" | "destructive" | "ghost"; |
| 82 | 86 | }) { |
| 83 | 87 | return ( |
| ... | ... | @@ -90,8 +94,10 @@ function MenuItem({ |
| 90 | 94 | size: "compact", |
| 91 | 95 | className: "h-auto min-h-7 w-full sm:text-xs", |
| 92 | 96 | }), |
| 97 | density === "touch" && "min-h-10 sm:min-h-10", | |
| 93 | 98 | className, |
| 94 | 99 | )} |
| 100 | data-density={density} | |
| 95 | 101 | data-inset={inset} |
| 96 | 102 | data-slot="menu-item" |
| 97 | 103 | data-variant={variant} |
| ... | ... | @@ -100,6 +106,21 @@ function MenuItem({ |
| 100 | 106 | ); |
| 101 | 107 | } |
| 102 | 108 | |
| 109 | // Trim font leading so visible letters center with the icons in touch rows. | |
| 110 | // Symmetric padding keeps accents and descenders inside truncated labels. | |
| 111 | function MenuItemLabel({ className, ...props }: React.ComponentProps<"span">) { | |
| 112 | return ( | |
| 113 | <span | |
| 114 | data-slot="menu-item-label" | |
| 115 | className={cn( | |
| 116 | "min-w-0 in-data-[density=touch]:[text-box:trim-both_cap_alphabetic] supports-[text-box:trim-both_cap_alphabetic]:in-data-[density=touch]:py-[0.5em]", | |
| 117 | className, | |
| 118 | )} | |
| 119 | {...props} | |
| 120 | /> | |
| 121 | ); | |
| 122 | } | |
| 123 | ||
| 103 | 124 | function MenuCheckboxItem({ |
| 104 | 125 | className, |
| 105 | 126 | children, |
| ... | ... | @@ -247,10 +268,12 @@ function MenuSub(props: MenuPrimitive.SubmenuRoot.Props) { |
| 247 | 268 | function MenuSubTrigger({ |
| 248 | 269 | className, |
| 249 | 270 | inset, |
| 271 | density = "default", | |
| 250 | 272 | children, |
| 251 | 273 | ...props |
| 252 | 274 | }: MenuPrimitive.SubmenuTrigger.Props & { |
| 253 | 275 | inset?: boolean; |
| 276 | density?: "default" | "touch"; | |
| 254 | 277 | }) { |
| 255 | 278 | return ( |
| 256 | 279 | <MenuPrimitive.SubmenuTrigger |
| ... | ... | @@ -261,8 +284,10 @@ function MenuSubTrigger({ |
| 261 | 284 | // also a direct svg — on a sub-trigger with no leading icon it is the |
| 262 | 285 | // only one, and these rules would take away its `ms-auto` alignment. |
| 263 | 286 | "[&>svg:not(:last-child)]:-mx-0.5 flex min-h-8 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:cursor-not-allowed data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&>svg:not(:last-child):not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&>svg]:shrink-0", |
| 287 | density === "touch" && "min-h-10 sm:min-h-10", | |
| 264 | 288 | className, |
| 265 | 289 | )} |
| 290 | data-density={density} | |
| 266 | 291 | data-inset={inset} |
| 267 | 292 | data-slot="menu-sub-trigger" |
| 268 | 293 | {...props} |
| ... | ... | @@ -309,6 +334,7 @@ export { |
| 309 | 334 | MenuGroup, |
| 310 | 335 | MenuGroup as DropdownMenuGroup, |
| 311 | 336 | MenuItem, |
| 337 | MenuItemLabel, | |
| 312 | 338 | MenuItem as DropdownMenuItem, |
| 313 | 339 | MenuCheckboxItem, |
| 314 | 340 | MenuCheckboxItem as DropdownMenuCheckboxItem, |
apps/web/src/components/ui/sidebar.tsx+5-2| ... | ... | @@ -155,7 +155,10 @@ function SidebarProvider({ |
| 155 | 155 | <SidebarContext value={contextValue}> |
| 156 | 156 | <div |
| 157 | 157 | // Inset layouts opt into bg-sidebar through className. |
| 158 | className={cn("group/sidebar-wrapper flex min-h-svh w-full", className)} | |
| 158 | className={cn( | |
| 159 | "group/sidebar-wrapper flex min-h-svh w-full max-sm:[--workspace-titlebar-control-size:--spacing(8)]", | |
| 160 | className, | |
| 161 | )} | |
| 159 | 162 | data-sidebar-state={state} |
| 160 | 163 | data-slot="sidebar-wrapper" |
| 161 | 164 | style={ |
| ... | ... | @@ -337,7 +340,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t |
| 337 | 340 | variant="ghost" |
| 338 | 341 | {...props} |
| 339 | 342 | > |
| 340 | {isOpen ? <PanelLeftCloseIcon /> : <PanelLeftIcon />} | |
| 343 | {isOpen ? <PanelLeftCloseIcon className="size-4" /> : <PanelLeftIcon className="size-4" />} | |
| 341 | 344 | <span className="sr-only">Toggle Sidebar</span> |
| 342 | 345 | </Button> |
| 343 | 346 | ); |