| 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 | variant = "default", |
| 20 | className, |
| 21 | ...props |
| 22 | }: Omit<ComponentProps<"span">, "children"> & { |
| 23 | value: string; |
| 24 | /** Characters kept at the end. Defaults to the last path segment, capped, or 10. */ |
| 25 | tail?: number; |
| 26 | /** The full value on hover. Off where a tooltip already carries it. */ |
| 27 | showTitle?: boolean; |
| 28 | /** Align capital letters with adjacent icons while preserving accents and descenders. */ |
| 29 | variant?: "default" | "cap-aligned"; |
| 30 | }) { |
| 31 | const split = splitForMiddleTruncate(value, tail); |
| 32 | return ( |
| 33 | <span |
| 34 | {...(showTitle ? { title: value } : {})} |
| 35 | className={cn( |
| 36 | "inline-flex min-w-0 max-w-full overflow-hidden whitespace-nowrap", |
| 37 | variant === "cap-aligned" && |
| 38 | "overflow-x-clip overflow-y-visible [&>span]:[text-box:trim-both_cap_alphabetic] [&>span]:overflow-x-clip [&>span]:overflow-y-visible", |
| 39 | className, |
| 40 | )} |
| 41 | {...props} |
| 42 | > |
| 43 | {split ? ( |
| 44 | <> |
| 45 | <span className="min-w-0 truncate">{split.head}</span> |
| 46 | <span className="shrink-0">{split.tail}</span> |
| 47 | </> |
| 48 | ) : ( |
| 49 | <span className="min-w-0 truncate">{value}</span> |
| 50 | )} |
| 51 | </span> |
| 52 | ); |
| 53 | } |
| 54 | |
| 55 | const DEFAULT_TAIL = 10; |
| 56 | const MAX_SEGMENT_TAIL = 16; |
| 57 | |
| 58 | /** |
| 59 | * Where to cut. A path keeps its last segment when that is short enough to be the useful |
| 60 | * part; anything else keeps a fixed count. Nothing is split when the tail would be most of |
| 61 | * the string, since then the head could never show enough to be worth an ellipsis. |
| 62 | */ |
| 63 | export function splitForMiddleTruncate( |
| 64 | value: string, |
| 65 | tail?: number, |
| 66 | ): { head: string; tail: string } | null { |
| 67 | // Code points, not UTF-16 units: a cut inside a surrogate pair would render two broken |
| 68 | // glyphs where an emoji or a CJK extension character used to be. |
| 69 | const chars = Array.from(value); |
| 70 | let keep = tail ?? DEFAULT_TAIL; |
| 71 | if (tail === undefined) { |
| 72 | const slash = chars.lastIndexOf("/"); |
| 73 | if (slash > 0 && slash < chars.length - 1) { |
| 74 | const segment = chars.length - slash - 1; |
| 75 | keep = segment <= MAX_SEGMENT_TAIL ? segment : DEFAULT_TAIL; |
| 76 | } |
| 77 | } |
| 78 | if (keep <= 0 || chars.length <= keep + 4) return null; |
| 79 | const cut = chars.length - keep; |
| 80 | return { head: chars.slice(0, cut).join(""), tail: chars.slice(cut).join("") }; |
| 81 | } |