| author | |
| committer | |
| log | f7efb5354a3e6fb5e6b13a658ba57020e2c554ad |
| tree | 3487173f9ac2e3c4c5cf14cdf13a616ed1b28159 |
| parent | 2efb8178d83f4f7c4ccc3ae1165f2a2ded0b2742 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>10 files changed, 137 insertions(+), 30 deletions(-)
apps/web/src/components/BranchToolbar.tsx+3-4| ... | ... | @@ -54,6 +54,7 @@ import { |
| 54 | 54 | } from "./ui/menu"; |
| 55 | 55 | import { Separator } from "./ui/separator"; |
| 56 | 56 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 57 | import { MiddleTruncate } from "./ui/middle-truncate"; | |
| 57 | 58 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 58 | 59 | import { useComposerMenuProps } from "./chat/composerEventScope"; |
| 59 | 60 | import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; |
| ... | ... | @@ -283,9 +284,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 283 | 284 | ) : ( |
| 284 | 285 | <FolderIcon className="size-3" /> |
| 285 | 286 | )} |
| 286 | <span className="min-w-0 truncate"> | |
| 287 | {resolveCurrentWorkspaceLabel(activeWorktreePath)} | |
| 288 | </span> | |
| 287 | <MiddleTruncate value={resolveCurrentWorkspaceLabel(activeWorktreePath)} /> | |
| 289 | 288 | </span> |
| 290 | 289 | </MenuRadioItem> |
| 291 | 290 | <MenuRadioItem disabled={envModeLocked} value="worktree" closeOnClick> |
| ... | ... | @@ -298,7 +297,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 298 | 297 | <MenuRadioItem disabled={envModeLocked} value="previous-worktree" closeOnClick> |
| 299 | 298 | <span className="flex min-w-0 items-center gap-1.5"> |
| 300 | 299 | <HistoryIcon className="size-3" /> |
| 301 | <span className="min-w-0 truncate">{previousWorktreeLabel}</span> | |
| 300 | <MiddleTruncate value={previousWorktreeLabel} /> | |
| 302 | 301 | </span> |
| 303 | 302 | </MenuRadioItem> |
| 304 | 303 | ) : null} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+6-6| ... | ... | @@ -73,6 +73,7 @@ import { |
| 73 | 73 | } from "./ui/combobox"; |
| 74 | 74 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 75 | 75 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 76 | import { MiddleTruncate } from "./ui/middle-truncate"; | |
| 76 | 77 | |
| 77 | 78 | export interface BranchToolbarBranchSelectorHandle { |
| 78 | 79 | open: () => void; |
| ... | ... | @@ -757,7 +758,7 @@ export function BranchToolbarBranchSelector({ |
| 757 | 758 | onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} |
| 758 | 759 | > |
| 759 | 760 | <div className="flex w-full min-w-0 items-center justify-between gap-2"> |
| 760 | <span className="min-w-0 flex-1 truncate">{itemValue}</span> | |
| 761 | <MiddleTruncate value={itemValue} className="flex-1" /> | |
| 761 | 762 | {badge && <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>} |
| 762 | 763 | </div> |
| 763 | 764 | </ComboboxItem> |
| ... | ... | @@ -818,12 +819,11 @@ export function BranchToolbarBranchSelector({ |
| 818 | 819 | data-composer-label |
| 819 | 820 | className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0" |
| 820 | 821 | > |
| 821 | <span | |
| 822 | <MiddleTruncate | |
| 823 | value={triggerLabel} | |
| 822 | 824 | data-composer-label-motion |
| 823 | className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none" | |
| 824 | > | |
| 825 | {triggerLabel} | |
| 826 | </span> | |
| 825 | className="flex w-full max-w-[240px] transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none" | |
| 826 | /> | |
| 827 | 827 | </span> |
| 828 | 828 | <ChevronDownIcon className="size-3 shrink-0 opacity-50" /> |
| 829 | 829 | </ComboboxTrigger> |
apps/web/src/components/Sidebar.tsx+7-4| ... | ... | @@ -242,6 +242,7 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom |
| 242 | 242 | import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; |
| 243 | 243 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; |
| 244 | 244 | import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; |
| 245 | import { MiddleTruncate } from "./ui/middle-truncate"; | |
| 245 | 246 | import { |
| 246 | 247 | composerDraftHasUserContent, |
| 247 | 248 | DraftId, |
| ... | ... | @@ -379,7 +380,7 @@ function SidebarThreadTooltip({ |
| 379 | 380 | {thread.branch ? ( |
| 380 | 381 | <div className="flex min-w-0 items-center gap-2"> |
| 381 | 382 | <GitBranchIcon className="size-3 shrink-0 stroke-muted-foreground" /> |
| 382 | <div className="min-w-0 truncate text-foreground/75">{thread.branch}</div> | |
| 383 | <MiddleTruncate value={thread.branch} className="flex text-foreground/75" /> | |
| 383 | 384 | </div> |
| 384 | 385 | ) : null} |
| 385 | 386 | {branchMismatch ? ( |
| ... | ... | @@ -1932,9 +1933,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1932 | 1933 | {thread.branch ? ( |
| 1933 | 1934 | <> |
| 1934 | 1935 | <ThreadWorktreeIndicator thread={thread} /> |
| 1935 | <span className="min-w-0 flex-1 truncate whitespace-nowrap text-muted-foreground/40"> | |
| 1936 | {thread.branch} | |
| 1937 | </span> | |
| 1936 | <MiddleTruncate | |
| 1937 | value={thread.branch} | |
| 1938 | showTitle={false} | |
| 1939 | className="flex-1 text-muted-foreground/40" | |
| 1940 | /> | |
| 1938 | 1941 | </> |
| 1939 | 1942 | ) : ( |
| 1940 | 1943 | <span className="flex-1" /> |
apps/web/src/components/ThreadCommandSubtitle.tsx+3-1| ... | ... | @@ -4,6 +4,8 @@ import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; |
| 4 | 4 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; |
| 5 | 5 | import { cn } from "~/lib/utils"; |
| 6 | 6 | |
| 7 | import { MiddleTruncate } from "./ui/middle-truncate"; | |
| 8 | ||
| 7 | 9 | /** |
| 8 | 10 | * Flip this while reviewing command-palette thread subtitles. |
| 9 | 11 | * - favicon-workspace-harness: favicon + Folder/FolderGit2 + branch + harness (default) |
| ... | ... | @@ -84,7 +86,7 @@ export function ThreadCommandSubtitle(props: { |
| 84 | 86 | {projectLabel ? <CommandPaletteMetaDot /> : null} |
| 85 | 87 | <span className="inline-flex min-w-0 items-center gap-1"> |
| 86 | 88 | <WorkspaceIcon variant={variant} isWorktree={isWorktree} /> |
| 87 | <span className="min-w-0 truncate">{branchLabel}</span> | |
| 89 | <MiddleTruncate value={branchLabel} /> | |
| 88 | 90 | </span> |
| 89 | 91 | </> |
| 90 | 92 | ) : null} |
apps/web/src/components/chat/ChangedFilesTree.tsx+5-3| ... | ... | @@ -19,6 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 19 | 19 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 20 | 20 | import { Button } from "../ui/button"; |
| 21 | 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 22 | import { MiddleTruncate } from "../ui/middle-truncate"; | |
| 22 | 23 | |
| 23 | 24 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; |
| 24 | 25 | |
| ... | ... | @@ -246,9 +247,10 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 246 | 247 | theme={resolvedTheme} |
| 247 | 248 | className="size-3.5 text-muted-foreground/70" |
| 248 | 249 | /> |
| 249 | <span className="truncate font-mono text-xs text-foreground/85 group-hover:text-foreground"> | |
| 250 | {node.name} | |
| 251 | </span> | |
| 250 | <MiddleTruncate | |
| 251 | value={node.name} | |
| 252 | className="font-mono text-xs text-foreground/85 group-hover:text-foreground" | |
| 253 | /> | |
| 252 | 254 | {node.stat && ( |
| 253 | 255 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> |
| 254 | 256 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> |
apps/web/src/components/chat/WorktreeSetupCard.tsx+10-3| ... | ... | @@ -19,6 +19,7 @@ import { useEffect, useState, type ReactNode } from "react"; |
| 19 | 19 | |
| 20 | 20 | import { Button } from "~/components/ui/button"; |
| 21 | 21 | import { Spinner } from "~/components/ui/spinner"; |
| 22 | import { MiddleTruncate } from "../ui/middle-truncate"; | |
| 22 | 23 | import { observeVisibleAnimation } from "~/lib/visibleAnimation"; |
| 23 | 24 | import { cn } from "~/lib/utils"; |
| 24 | 25 | |
| ... | ... | @@ -260,19 +261,25 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { |
| 260 | 261 | {snapshot.branch ? ( |
| 261 | 262 | <> |
| 262 | 263 | <dt className="text-foreground/80">Branch</dt> |
| 263 | <dd className="truncate font-mono">{snapshot.branch}</dd> | |
| 264 | <dd className="min-w-0 font-mono"> | |
| 265 | <MiddleTruncate value={snapshot.branch} className="flex" /> | |
| 266 | </dd> | |
| 264 | 267 | </> |
| 265 | 268 | ) : null} |
| 266 | 269 | {snapshot.baseRef ? ( |
| 267 | 270 | <> |
| 268 | 271 | <dt className="text-foreground/80">Base</dt> |
| 269 | <dd className="truncate font-mono">{snapshot.baseRef}</dd> | |
| 272 | <dd className="min-w-0 font-mono"> | |
| 273 | <MiddleTruncate value={snapshot.baseRef} className="flex" /> | |
| 274 | </dd> | |
| 270 | 275 | </> |
| 271 | 276 | ) : null} |
| 272 | 277 | {snapshot.worktreePath ? ( |
| 273 | 278 | <> |
| 274 | 279 | <dt className="text-foreground/80">Path</dt> |
| 275 | <dd className="truncate font-mono">{snapshot.worktreePath}</dd> | |
| 280 | <dd className="min-w-0 font-mono"> | |
| 281 | <MiddleTruncate value={snapshot.worktreePath} className="flex" /> | |
| 282 | </dd> | |
| 276 | 283 | </> |
| 277 | 284 | ) : null} |
| 278 | 285 | {snapshot.setupScript ? ( |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+16-5| ... | ... | @@ -121,6 +121,7 @@ import { |
| 121 | 121 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 122 | 122 | import { toastManager } from "../ui/toast"; |
| 123 | 123 | import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; |
| 124 | import { MiddleTruncate } from "../ui/middle-truncate"; | |
| 124 | 125 | import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; |
| 125 | 126 | import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; |
| 126 | 127 | import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; |
| ... | ... | @@ -2267,7 +2268,9 @@ export function PullRequestDetailPanel({ |
| 2267 | 2268 | className="size-3 shrink-0" |
| 2268 | 2269 | /> |
| 2269 | 2270 | ) : null} |
| 2270 | <code className="min-w-0 truncate">{detail.baseBranch}</code> | |
| 2271 | <code className="flex min-w-0"> | |
| 2272 | <MiddleTruncate value={detail.baseBranch} showTitle={false} /> | |
| 2273 | </code> | |
| 2271 | 2274 | </PullRequestBaseFreshnessWarning> |
| 2272 | 2275 | ) : ( |
| 2273 | 2276 | <Tooltip> |
| ... | ... | @@ -2280,7 +2283,9 @@ export function PullRequestDetailPanel({ |
| 2280 | 2283 | className="size-3 shrink-0" |
| 2281 | 2284 | /> |
| 2282 | 2285 | ) : null} |
| 2283 | <code className="min-w-0 truncate">{detail.baseBranch}</code> | |
| 2286 | <code className="flex min-w-0"> | |
| 2287 | <MiddleTruncate value={detail.baseBranch} showTitle={false} /> | |
| 2288 | </code> | |
| 2284 | 2289 | </span> |
| 2285 | 2290 | } |
| 2286 | 2291 | /> |
| ... | ... | @@ -2298,7 +2303,9 @@ export function PullRequestDetailPanel({ |
| 2298 | 2303 | <Tooltip> |
| 2299 | 2304 | <TooltipTrigger |
| 2300 | 2305 | render={ |
| 2301 | <code className="min-w-0 flex-1 truncate">{detail.headBranch}</code> | |
| 2306 | <code className="flex min-w-0 flex-1"> | |
| 2307 | <MiddleTruncate value={detail.headBranch} showTitle={false} /> | |
| 2308 | </code> | |
| 2302 | 2309 | } |
| 2303 | 2310 | /> |
| 2304 | 2311 | <TooltipPopup side="top">{detail.headBranch}</TooltipPopup> |
| ... | ... | @@ -2452,7 +2459,9 @@ export function PullRequestDetailPanel({ |
| 2452 | 2459 | className="size-3 shrink-0" |
| 2453 | 2460 | /> |
| 2454 | 2461 | ) : null} |
| 2455 | <code className="min-w-0 truncate">{detail.baseBranch}</code> | |
| 2462 | <code className="flex min-w-0"> | |
| 2463 | <MiddleTruncate value={detail.baseBranch} showTitle={false} /> | |
| 2464 | </code> | |
| 2456 | 2465 | </PullRequestBaseFreshnessWarning> |
| 2457 | 2466 | ) : ( |
| 2458 | 2467 | <Tooltip> |
| ... | ... | @@ -2465,7 +2474,9 @@ export function PullRequestDetailPanel({ |
| 2465 | 2474 | className="size-3 shrink-0" |
| 2466 | 2475 | /> |
| 2467 | 2476 | ) : null} |
| 2468 | <code className="min-w-0 truncate">{detail.baseBranch}</code> | |
| 2477 | <code className="flex min-w-0"> | |
| 2478 | <MiddleTruncate value={detail.baseBranch} showTitle={false} /> | |
| 2479 | </code> | |
| 2469 | 2480 | </span> |
| 2470 | 2481 | } |
| 2471 | 2482 | /> |
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+4-1| ... | ... | @@ -27,6 +27,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 27 | 27 | |
| 28 | 28 | import { Button, InlineButton } from "../ui/button"; |
| 29 | 29 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 30 | import { MiddleTruncate } from "../ui/middle-truncate"; | |
| 30 | 31 | import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; |
| 31 | 32 | import { |
| 32 | 33 | PullRequestActorLabel, |
| ... | ... | @@ -252,7 +253,9 @@ export function PullRequestDetailGhost({ |
| 252 | 253 | <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70"> |
| 253 | 254 | {seed ? ( |
| 254 | 255 | <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1"> |
| 255 | <code className="min-w-0 truncate">{seed.baseBranch}</code> | |
| 256 | <code className="flex min-w-0"> | |
| 257 | <MiddleTruncate value={seed.baseBranch} /> | |
| 258 | </code> | |
| 256 | 259 | </span> |
| 257 | 260 | ) : ( |
| 258 | 261 | <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1"> |
apps/web/src/components/pullRequest/PullRequestListRow.tsx+10-3| ... | ... | @@ -8,6 +8,7 @@ import type { ReactNode } from "react"; |
| 8 | 8 | import { cn } from "~/lib/utils"; |
| 9 | 9 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 10 | 10 | |
| 11 | import { MiddleTruncate } from "../ui/middle-truncate"; | |
| 11 | 12 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 12 | 13 | import { |
| 13 | 14 | PullRequestActorAvatar, |
| ... | ... | @@ -148,11 +149,17 @@ export function PullRequestRowAuthor({ |
| 148 | 149 | ); |
| 149 | 150 | } |
| 150 | 151 | |
| 151 | /** `head → base`, in the mono the branches are typed in. */ | |
| 152 | /** | |
| 153 | * `head → base`, in the mono the branches are typed in, each cut in the middle when the row is | |
| 154 | * short of room. The base keeps its width up to a share of the line, so a long head cannot | |
| 155 | * squeeze a short `main` out; the arrow stays readable so the two are not read as one name. | |
| 156 | */ | |
| 152 | 157 | export function PullRequestRowBranches({ head, base }: { head: string; base: string }) { |
| 153 | 158 | return ( |
| 154 | <span className="truncate font-mono"> | |
| 155 | {head} → {base} | |
| 159 | <span className="flex min-w-0 items-center gap-1 font-mono"> | |
| 160 | <MiddleTruncate value={head} /> | |
| 161 | <span className="shrink-0">→</span> | |
| 162 | <MiddleTruncate value={base} className="max-w-[45%] shrink-0" /> | |
| 156 | 163 | </span> |
| 157 | 164 | ); |
| 158 | 165 | } |
apps/web/src/components/ui/middle-truncate.tsx created+73| ... | ... | @@ -0,0 +1,73 @@ |
| 1 | import type { ComponentProps } from "react"; | |
| 2 | ||
| 3 | import { cn } from "~/lib/utils"; | |
| 4 | ||
| 5 | /** | |
| 6 | * Truncates in the middle, the way Finder does, for strings that carry meaning at both ends: | |
| 7 | * branch names, paths, worktree names, shas. A tail-cut `fix/cache-main-20260918-180825` loses | |
| 8 | * the date that tells two branches apart; a path loses its file name. | |
| 9 | * | |
| 10 | * CSS has no middle ellipsis, so the string is split into a head that truncates and a tail | |
| 11 | * that does not. No measuring and no observers, so it costs the same as `truncate` in a long | |
| 12 | * list and works inside `content-visibility` skipping. Both halves are real text: selection | |
| 13 | * and copy give the whole string, and a screen reader reads it through. | |
| 14 | */ | |
| 15 | export function MiddleTruncate({ | |
| 16 | value, | |
| 17 | tail, | |
| 18 | showTitle = true, | |
| 19 | className, | |
| 20 | ...props | |
| 21 | }: Omit<ComponentProps<"span">, "children"> & { | |
| 22 | value: string; | |
| 23 | /** Characters kept at the end. Defaults to the last path segment, capped, or 10. */ | |
| 24 | tail?: number; | |
| 25 | /** The full value on hover. Off where a tooltip already carries it. */ | |
| 26 | showTitle?: boolean; | |
| 27 | }) { | |
| 28 | const split = splitForMiddleTruncate(value, tail); | |
| 29 | return ( | |
| 30 | <span | |
| 31 | {...(showTitle ? { title: value } : {})} | |
| 32 | className={cn("inline-flex min-w-0 max-w-full overflow-hidden whitespace-nowrap", className)} | |
| 33 | {...props} | |
| 34 | > | |
| 35 | {split ? ( | |
| 36 | <> | |
| 37 | <span className="min-w-0 truncate">{split.head}</span> | |
| 38 | <span className="shrink-0">{split.tail}</span> | |
| 39 | </> | |
| 40 | ) : ( | |
| 41 | <span className="min-w-0 truncate">{value}</span> | |
| 42 | )} | |
| 43 | </span> | |
| 44 | ); | |
| 45 | } | |
| 46 | ||
| 47 | const DEFAULT_TAIL = 10; | |
| 48 | const MAX_SEGMENT_TAIL = 16; | |
| 49 | ||
| 50 | /** | |
| 51 | * Where to cut. A path keeps its last segment when that is short enough to be the useful | |
| 52 | * part; anything else keeps a fixed count. Nothing is split when the tail would be most of | |
| 53 | * the string, since then the head could never show enough to be worth an ellipsis. | |
| 54 | */ | |
| 55 | export function splitForMiddleTruncate( | |
| 56 | value: string, | |
| 57 | tail?: number, | |
| 58 | ): { head: string; tail: string } | null { | |
| 59 | // Code points, not UTF-16 units: a cut inside a surrogate pair would render two broken | |
| 60 | // glyphs where an emoji or a CJK extension character used to be. | |
| 61 | const chars = Array.from(value); | |
| 62 | let keep = tail ?? DEFAULT_TAIL; | |
| 63 | if (tail === undefined) { | |
| 64 | const slash = chars.lastIndexOf("/"); | |
| 65 | if (slash > 0 && slash < chars.length - 1) { | |
| 66 | const segment = chars.length - slash - 1; | |
| 67 | keep = segment <= MAX_SEGMENT_TAIL ? segment : DEFAULT_TAIL; | |
| 68 | } | |
| 69 | } | |
| 70 | if (keep <= 0 || chars.length <= keep + 4) return null; | |
| 71 | const cut = chars.length - keep; | |
| 72 | return { head: chars.slice(0, cut).join(""), tail: chars.slice(cut).join("") }; | |
| 73 | } |