1import type { ComponentProps } from "react";
2
3import { 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 */
15export 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
55const DEFAULT_TAIL = 10;
56const 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 */
63export 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}