| author | |
| committer | |
| log | ce98d4107758ed7a492f94a93b3685a828caeba1 |
| tree | af4b0ca5130b373cd40e1cd68497d87f84c60476 |
| parent | 1a81ea8c153214ddc74b7046062a8957c2612e8d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>4 files changed, 129 insertions(+), 111 deletions(-)
apps/web/src/components/DiffPanel.tsx+55-53| ... | @@ -72,7 +72,8 @@ import { | ... | @@ -72,7 +72,8 @@ import { |
| 72 | import { | 72 | import { |
| 73 | DropdownMenu, | 73 | DropdownMenu, |
| 74 | DropdownMenuContent, | 74 | DropdownMenuContent, |
| 75 | DropdownMenuItem, | 75 | DropdownMenuRadioGroup, |
| 76 | DropdownMenuRadioItem, | ||
| 76 | DropdownMenuSub, | 77 | DropdownMenuSub, |
| 77 | DropdownMenuSubContent, | 78 | DropdownMenuSubContent, |
| 78 | DropdownMenuSubTrigger, | 79 | DropdownMenuSubTrigger, |
| ... | @@ -645,6 +646,26 @@ export default function DiffPanel({ | ... | @@ -645,6 +646,26 @@ export default function DiffPanel({ |
| 645 | if (!routeThreadRef) return; | 646 | if (!routeThreadRef) return; |
| 646 | useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); | 647 | useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); |
| 647 | }; | 648 | }; |
| 649 | // The scope menu has two radio groups: the top-level one treats the latest | ||
| 650 | // turn as "latest", while the turn sub-menu keys every turn by id so the | ||
| 651 | // latest turn is also marked there. | ||
| 652 | const selectedTurnValue = selectedTurn ? `turn:${selectedTurn.turnId}` : ""; | ||
| 653 | const selectedScopeValue = | ||
| 654 | selectedTurnId === null | ||
| 655 | ? selectedGitScope | ||
| 656 | : selectedTurn?.turnId === latestTurn?.turnId | ||
| 657 | ? "latest" | ||
| 658 | : selectedTurnValue; | ||
| 659 | const selectScopeValue = (value: string) => { | ||
| 660 | if (value === "unstaged" || value === "branch") { | ||
| 661 | selectGitScope(value); | ||
| 662 | } else if (value === "latest") { | ||
| 663 | if (latestTurn) selectTurn(latestTurn.turnId); | ||
| 664 | } else { | ||
| 665 | const turn = orderedTurnDiffSummaries.find((summary) => `turn:${summary.turnId}` === value); | ||
| 666 | if (turn) selectTurn(turn.turnId); | ||
| 667 | } | ||
| 668 | }; | ||
| 648 | 669 | ||
| 649 | const headerRow = ( | 670 | const headerRow = ( |
| 650 | <> | 671 | <> |
| ... | @@ -658,61 +679,42 @@ export default function DiffPanel({ | ... | @@ -658,61 +679,42 @@ export default function DiffPanel({ |
| 658 | <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" /> | 679 | <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" /> |
| 659 | </DropdownMenuTrigger> | 680 | </DropdownMenuTrigger> |
| 660 | <DropdownMenuContent align="start" className="w-60"> | 681 | <DropdownMenuContent align="start" className="w-60"> |
| 661 | <DropdownMenuItem | 682 | <DropdownMenuRadioGroup value={selectedScopeValue} onValueChange={selectScopeValue}> |
| 662 | className={ | 683 | <DropdownMenuRadioItem value="unstaged" closeOnClick> |
| 663 | selectedTurnId === null && selectedGitScope === "unstaged" | 684 | <span>Working tree</span> |
| 664 | ? "bg-foreground/[0.08]" | 685 | </DropdownMenuRadioItem> |
| 665 | : undefined | 686 | <DropdownMenuRadioItem value="branch" closeOnClick> |
| 666 | } | 687 | <span>Branch changes</span> |
| 667 | onClick={() => selectGitScope("unstaged")} | 688 | </DropdownMenuRadioItem> |
| 668 | > | 689 | <DropdownMenuRadioItem value="latest" closeOnClick> |
| 669 | <span>Working tree</span> | 690 | <span>Latest turn</span> |
| 670 | </DropdownMenuItem> | 691 | </DropdownMenuRadioItem> |
| 671 | <DropdownMenuItem | 692 | </DropdownMenuRadioGroup> |
| 672 | className={ | ||
| 673 | selectedTurnId === null && selectedGitScope === "branch" | ||
| 674 | ? "bg-foreground/[0.08]" | ||
| 675 | : undefined | ||
| 676 | } | ||
| 677 | onClick={() => selectGitScope("branch")} | ||
| 678 | > | ||
| 679 | <span>Branch changes</span> | ||
| 680 | </DropdownMenuItem> | ||
| 681 | <DropdownMenuItem | ||
| 682 | className={ | ||
| 683 | selectedTurnId !== null && selectedTurn?.turnId === latestTurn?.turnId | ||
| 684 | ? "bg-foreground/[0.08]" | ||
| 685 | : undefined | ||
| 686 | } | ||
| 687 | onClick={() => { | ||
| 688 | if (latestTurn) selectTurn(latestTurn.turnId); | ||
| 689 | }} | ||
| 690 | > | ||
| 691 | <span>Latest turn</span> | ||
| 692 | </DropdownMenuItem> | ||
| 693 | <DropdownMenuSub> | 693 | <DropdownMenuSub> |
| 694 | <DropdownMenuSubTrigger>Turn</DropdownMenuSubTrigger> | 694 | <DropdownMenuSubTrigger>Turn</DropdownMenuSubTrigger> |
| 695 | <DropdownMenuSubContent className="w-64"> | 695 | <DropdownMenuSubContent className="w-64"> |
| 696 | {orderedTurnDiffSummaries.map((summary) => { | 696 | <DropdownMenuRadioGroup value={selectedTurnValue} onValueChange={selectScopeValue}> |
| 697 | const turnCount = | 697 | {orderedTurnDiffSummaries.map((summary) => { |
| 698 | summary.checkpointTurnCount ?? | 698 | const turnCount = |
| 699 | inferredCheckpointTurnCountByTurnId[summary.turnId] ?? | 699 | summary.checkpointTurnCount ?? |
| 700 | "?"; | 700 | inferredCheckpointTurnCountByTurnId[summary.turnId] ?? |
| 701 | return ( | 701 | "?"; |
| 702 | <DropdownMenuItem | 702 | return ( |
| 703 | key={summary.turnId} | 703 | <DropdownMenuRadioItem |
| 704 | className={ | 704 | key={summary.turnId} |
| 705 | summary.turnId === selectedTurn?.turnId ? "bg-foreground/[0.08]" : undefined | 705 | value={`turn:${summary.turnId}`} |
| 706 | } | 706 | closeOnClick |
| 707 | onClick={() => selectTurn(summary.turnId)} | 707 | > |
| 708 | > | 708 | <span className="flex items-center gap-2"> |
| 709 | <span>Turn {turnCount}</span> | 709 | <span>Turn {turnCount}</span> |
| 710 | <span className="ml-auto text-xs tabular-nums text-muted-foreground"> | 710 | <span className="ml-auto text-xs tabular-nums text-muted-foreground"> |
| 711 | {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} | 711 | {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} |
| 712 | </span> | 712 | </span> |
| 713 | </DropdownMenuItem> | 713 | </span> |
| 714 | ); | 714 | </DropdownMenuRadioItem> |
| 715 | })} | 715 | ); |
| 716 | })} | ||
| 717 | </DropdownMenuRadioGroup> | ||
| 716 | </DropdownMenuSubContent> | 718 | </DropdownMenuSubContent> |
| 717 | </DropdownMenuSub> | 719 | </DropdownMenuSub> |
| 718 | </DropdownMenuContent> | 720 | </DropdownMenuContent> |
apps/web/src/components/files/FileBreadcrumbs.tsx+47-33| ... | @@ -10,6 +10,8 @@ import { | ... | @@ -10,6 +10,8 @@ import { |
| 10 | MenuGroup, | 10 | MenuGroup, |
| 11 | MenuItem, | 11 | MenuItem, |
| 12 | MenuPopup, | 12 | MenuPopup, |
| 13 | MenuRadioGroup, | ||
| 14 | MenuRadioItem, | ||
| 13 | MenuSeparator, | 15 | MenuSeparator, |
| 14 | MenuTrigger, | 16 | MenuTrigger, |
| 15 | } from "~/components/ui/menu"; | 17 | } from "~/components/ui/menu"; |
| ... | @@ -141,39 +143,51 @@ function BreadcrumbMenuContent(props: { | ... | @@ -141,39 +143,51 @@ function BreadcrumbMenuContent(props: { |
| 141 | : "This folder is empty."} | 143 | : "This folder is empty."} |
| 142 | </MenuItem> | 144 | </MenuItem> |
| 143 | ) : ( | 145 | ) : ( |
| 144 | children.map((entry) => { | 146 | // Files form a radio group keyed by path so the open file is marked as checked; |
| 145 | const isCurrentFile = entry.kind === "file" && entry.path === props.currentFilePath; | 147 | // directories only navigate the menu, so they stay plain items. |
| 146 | return ( | 148 | <MenuRadioGroup |
| 147 | <MenuItem | 149 | value={props.currentFilePath} |
| 148 | key={entry.path} | 150 | onValueChange={(path) => { |
| 149 | closeOnClick={entry.kind === "file"} | 151 | props.onOpenChange(false); |
| 150 | aria-current={isCurrentFile ? "page" : undefined} | 152 | props.onOpenFile(path); |
| 151 | className={cn( | 153 | }} |
| 152 | isCurrentFile && "bg-foreground/[0.08]", | 154 | > |
| 153 | entry.ignored && "text-muted-foreground", | 155 | {children.map((entry) => { |
| 154 | )} | 156 | const isCurrentFile = entry.kind === "file" && entry.path === props.currentFilePath; |
| 155 | onClick={() => { | 157 | const row = ( |
| 156 | if (entry.kind === "directory") { | 158 | <> |
| 157 | props.onDirectoryChange(entry.path); | 159 | <PierreEntryIcon pathValue={entry.path} kind={entry.kind} theme={resolvedTheme} /> |
| 158 | return; | 160 | <Tooltip> |
| 159 | } | 161 | <TooltipTrigger render={<span className="min-w-0 flex-1 truncate" />}> |
| 160 | props.onOpenChange(false); | 162 | {entry.label} |
| 161 | props.onOpenFile(entry.path); | 163 | </TooltipTrigger> |
| 162 | }} | 164 | <TooltipPopup side="right">{entry.path}</TooltipPopup> |
| 163 | > | 165 | </Tooltip> |
| 164 | <PierreEntryIcon pathValue={entry.path} kind={entry.kind} theme={resolvedTheme} /> | 166 | </> |
| 165 | <Tooltip> | 167 | ); |
| 166 | <TooltipTrigger render={<span className="min-w-0 flex-1 truncate" />}> | 168 | return entry.kind === "directory" ? ( |
| 167 | {entry.label} | 169 | <MenuItem |
| 168 | </TooltipTrigger> | 170 | key={entry.path} |
| 169 | <TooltipPopup side="right" className="max-w-80"> | 171 | closeOnClick={false} |
| 170 | {entry.path} | 172 | className={entry.ignored ? "text-muted-foreground" : undefined} |
| 171 | </TooltipPopup> | 173 | onClick={() => props.onDirectoryChange(entry.path)} |
| 172 | </Tooltip> | 174 | > |
| 173 | {entry.kind === "directory" ? <ChevronRightIcon /> : null} | 175 | {row} |
| 174 | </MenuItem> | 176 | <ChevronRightIcon /> |
| 175 | ); | 177 | </MenuItem> |
| 176 | }) | 178 | ) : ( |
| 179 | <MenuRadioItem | ||
| 180 | key={entry.path} | ||
| 181 | value={entry.path} | ||
| 182 | closeOnClick | ||
| 183 | aria-current={isCurrentFile ? "page" : undefined} | ||
| 184 | className={entry.ignored ? "text-muted-foreground" : undefined} | ||
| 185 | > | ||
| 186 | <span className="flex min-w-0 items-center gap-2">{row}</span> | ||
| 187 | </MenuRadioItem> | ||
| 188 | ); | ||
| 189 | })} | ||
| 190 | </MenuRadioGroup> | ||
| 177 | )} | 191 | )} |
| 178 | </MenuGroup> | 192 | </MenuGroup> |
| 179 | {entriesQuery.error && entriesQuery.data !== null ? ( | 193 | {entriesQuery.error && entriesQuery.data !== null ? ( |
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+26-24| ... | @@ -70,6 +70,8 @@ import { | ... | @@ -70,6 +70,8 @@ import { |
| 70 | DropdownMenu, | 70 | DropdownMenu, |
| 71 | DropdownMenuContent, | 71 | DropdownMenuContent, |
| 72 | DropdownMenuItem, | 72 | DropdownMenuItem, |
| 73 | DropdownMenuRadioGroup, | ||
| 74 | DropdownMenuRadioItem, | ||
| 73 | DropdownMenuTrigger, | 75 | DropdownMenuTrigger, |
| 74 | } from "../ui/menu"; | 76 | } from "../ui/menu"; |
| 75 | import { toastManager } from "../ui/toast"; | 77 | import { toastManager } from "../ui/toast"; |
| ... | @@ -1076,31 +1078,31 @@ function PullRequestCodeTab({ | ... | @@ -1076,31 +1078,31 @@ function PullRequestCodeTab({ |
| 1076 | <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" /> | 1078 | <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" /> |
| 1077 | </DropdownMenuTrigger> | 1079 | </DropdownMenuTrigger> |
| 1078 | <DropdownMenuContent align="start" className="w-80"> | 1080 | <DropdownMenuContent align="start" className="w-80"> |
| 1079 | <DropdownMenuItem | 1081 | <DropdownMenuRadioGroup |
| 1080 | className={commit === null ? "bg-foreground/[0.08]" : undefined} | 1082 | value={commit ?? "all"} |
| 1081 | onClick={() => onSelectedCommitChange(null)} | 1083 | onValueChange={(value) => onSelectedCommitChange(value === "all" ? null : value)} |
| 1082 | > | 1084 | > |
| 1083 | <span>All commits</span> | 1085 | <DropdownMenuRadioItem value="all" closeOnClick> |
| 1084 | </DropdownMenuItem> | 1086 | <span>All commits</span> |
| 1085 | {orderedCommits.slice(0, visibleCommitCount).map((entry) => ( | 1087 | </DropdownMenuRadioItem> |
| 1086 | <DropdownMenuItem | 1088 | {orderedCommits.slice(0, visibleCommitCount).map((entry) => ( |
| 1087 | key={entry.oid} | 1089 | <DropdownMenuRadioItem key={entry.oid} value={entry.oid} closeOnClick> |
| 1088 | className={entry.oid === commit ? "bg-foreground/[0.08]" : undefined} | 1090 | {/* Headlines run long, and the abbreviated oid after one is what a reader |
| 1089 | onClick={() => onSelectedCommitChange(entry.oid)} | 1091 | matches against the commit list on the host. */} |
| 1090 | > | 1092 | <span className="flex items-center gap-2"> |
| 1091 | {/* Headlines run long, and the abbreviated oid after one is what a reader | 1093 | <Tooltip> |
| 1092 | matches against the commit list on the host. */} | 1094 | <TooltipTrigger |
| 1093 | <Tooltip> | 1095 | render={<span className="min-w-0 truncate">{entry.messageHeadline}</span>} |
| 1094 | <TooltipTrigger | 1096 | /> |
| 1095 | render={<span className="min-w-0 truncate">{entry.messageHeadline}</span>} | 1097 | <TooltipPopup side="top">{entry.messageHeadline}</TooltipPopup> |
| 1096 | /> | 1098 | </Tooltip> |
| 1097 | <TooltipPopup side="top">{entry.messageHeadline}</TooltipPopup> | 1099 | <span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground"> |
| 1098 | </Tooltip> | 1100 | {entry.oid.slice(0, 7)} |
| 1099 | <span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground"> | 1101 | </span> |
| 1100 | {entry.oid.slice(0, 7)} | 1102 | </span> |
| 1101 | </span> | 1103 | </DropdownMenuRadioItem> |
| 1102 | </DropdownMenuItem> | 1104 | ))} |
| 1103 | ))} | 1105 | </DropdownMenuRadioGroup> |
| 1104 | {orderedCommits.length > visibleCommitCount ? ( | 1106 | {orderedCommits.length > visibleCommitCount ? ( |
| 1105 | // Kept out of the radio group: it changes how much of the list is on screen | 1107 | // Kept out of the radio group: it changes how much of the list is on screen |
| 1106 | // rather than what the diff is scoped to. | 1108 | // rather than what the diff is scoped to. |
scripts/lint-restyle-ceiling.ts+1-1| ... | @@ -8,7 +8,7 @@ import * as NodeURL from "node:url"; | ... | @@ -8,7 +8,7 @@ import * as NodeURL from "node:url"; |
| 8 | // components/ui exports are migrated to variants (see vite.config.ts). This gate keeps the | 8 | // components/ui exports are migrated to variants (see vite.config.ts). This gate keeps the |
| 9 | // count from growing: CI fails when findings exceed the ceiling. Lower the ceiling when you | 9 | // count from growing: CI fails when findings exceed the ceiling. Lower the ceiling when you |
| 10 | // migrate a file, and delete this script when the rule becomes an error. | 10 | // migrate a file, and delete this script when the rule becomes an error. |
| 11 | export const RESTYLE_CEILING = 1165; | 11 | export const RESTYLE_CEILING = 1159; |
| 12 | 12 | ||
| 13 | const RULE = "shadcn(no-restyle)"; | 13 | const RULE = "shadcn(no-restyle)"; |
| 14 | 14 |