authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-20 23:43:39-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 23:43:39-03:00
logf7efb5354a3e6fb5e6b13a658ba57020e2c554ad
tree3487173f9ac2e3c4c5cf14cdf13a616ed1b28159
parent2efb8178d83f4f7c4ccc3ae1165f2a2ded0b2742
signature Signed by PGP key B5690EEEBB952194

feat(web): truncate branch names and paths in the middle (#12805)

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,6 +54,7 @@ import {
54} from "./ui/menu";54} from "./ui/menu";
55import { Separator } from "./ui/separator";55import { Separator } from "./ui/separator";
56import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";56import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
57import { MiddleTruncate } from "./ui/middle-truncate";
57import { ComposerSurface } from "./chat/ComposerSurface";58import { ComposerSurface } from "./chat/ComposerSurface";
58import { useComposerMenuProps } from "./chat/composerEventScope";59import { useComposerMenuProps } from "./chat/composerEventScope";
59import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";60import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";
...@@ -283,9 +284,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -283,9 +284,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
283 ) : (284 ) : (
284 <FolderIcon className="size-3" />285 <FolderIcon className="size-3" />
285 )}286 )}
286 <span className="min-w-0 truncate">287 <MiddleTruncate value={resolveCurrentWorkspaceLabel(activeWorktreePath)} />
287 {resolveCurrentWorkspaceLabel(activeWorktreePath)}
288 </span>
289 </span>288 </span>
290 </MenuRadioItem>289 </MenuRadioItem>
291 <MenuRadioItem disabled={envModeLocked} value="worktree" closeOnClick>290 <MenuRadioItem disabled={envModeLocked} value="worktree" closeOnClick>
...@@ -298,7 +297,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -298,7 +297,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
298 <MenuRadioItem disabled={envModeLocked} value="previous-worktree" closeOnClick>297 <MenuRadioItem disabled={envModeLocked} value="previous-worktree" closeOnClick>
299 <span className="flex min-w-0 items-center gap-1.5">298 <span className="flex min-w-0 items-center gap-1.5">
300 <HistoryIcon className="size-3" />299 <HistoryIcon className="size-3" />
301 <span className="min-w-0 truncate">{previousWorktreeLabel}</span>300 <MiddleTruncate value={previousWorktreeLabel} />
302 </span>301 </span>
303 </MenuRadioItem>302 </MenuRadioItem>
304 ) : null}303 ) : null}
apps/web/src/components/BranchToolbarBranchSelector.tsx+6-6
...@@ -73,6 +73,7 @@ import {...@@ -73,6 +73,7 @@ import {
73} from "./ui/combobox";73} from "./ui/combobox";
74import { stackedThreadToast, toastManager } from "./ui/toast";74import { stackedThreadToast, toastManager } from "./ui/toast";
75import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";75import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
76import { MiddleTruncate } from "./ui/middle-truncate";
7677
77export interface BranchToolbarBranchSelectorHandle {78export interface BranchToolbarBranchSelectorHandle {
78 open: () => void;79 open: () => void;
...@@ -757,7 +758,7 @@ export function BranchToolbarBranchSelector({...@@ -757,7 +758,7 @@ export function BranchToolbarBranchSelector({
757 onContextMenu={(event) => handleBranchContextMenu(event, itemValue)}758 onContextMenu={(event) => handleBranchContextMenu(event, itemValue)}
758 >759 >
759 <div className="flex w-full min-w-0 items-center justify-between gap-2">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 {badge && <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>}762 {badge && <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>}
762 </div>763 </div>
763 </ComboboxItem>764 </ComboboxItem>
...@@ -818,12 +819,11 @@ export function BranchToolbarBranchSelector({...@@ -818,12 +819,11 @@ export function BranchToolbarBranchSelector({
818 data-composer-label819 data-composer-label
819 className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"820 className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"
820 >821 >
821 <span822 <MiddleTruncate
823 value={triggerLabel}
822 data-composer-label-motion824 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"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"
824 >826 />
825 {triggerLabel}
826 </span>
827 </span>827 </span>
828 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />828 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
829 </ComboboxTrigger>829 </ComboboxTrigger>
apps/web/src/components/Sidebar.tsx+7-4
...@@ -242,6 +242,7 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom...@@ -242,6 +242,7 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom
242import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader";242import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader";
243import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";243import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
244import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";244import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
245import { MiddleTruncate } from "./ui/middle-truncate";
245import {246import {
246 composerDraftHasUserContent,247 composerDraftHasUserContent,
247 DraftId,248 DraftId,
...@@ -379,7 +380,7 @@ function SidebarThreadTooltip({...@@ -379,7 +380,7 @@ function SidebarThreadTooltip({
379 {thread.branch ? (380 {thread.branch ? (
380 <div className="flex min-w-0 items-center gap-2">381 <div className="flex min-w-0 items-center gap-2">
381 <GitBranchIcon className="size-3 shrink-0 stroke-muted-foreground" />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 </div>384 </div>
384 ) : null}385 ) : null}
385 {branchMismatch ? (386 {branchMismatch ? (
...@@ -1932,9 +1933,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1932,9 +1933,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1932 {thread.branch ? (1933 {thread.branch ? (
1933 <>1934 <>
1934 <ThreadWorktreeIndicator thread={thread} />1935 <ThreadWorktreeIndicator thread={thread} />
1935 <span className="min-w-0 flex-1 truncate whitespace-nowrap text-muted-foreground/40">1936 <MiddleTruncate
1936 {thread.branch}1937 value={thread.branch}
1937 </span>1938 showTitle={false}
1939 className="flex-1 text-muted-foreground/40"
1940 />
1938 </>1941 </>
1939 ) : (1942 ) : (
1940 <span className="flex-1" />1943 <span className="flex-1" />
apps/web/src/components/ThreadCommandSubtitle.tsx+3-1
...@@ -4,6 +4,8 @@ import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";...@@ -4,6 +4,8 @@ import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
4import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";4import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
5import { cn } from "~/lib/utils";5import { cn } from "~/lib/utils";
66
7import { MiddleTruncate } from "./ui/middle-truncate";
8
7/**9/**
8 * Flip this while reviewing command-palette thread subtitles.10 * Flip this while reviewing command-palette thread subtitles.
9 * - favicon-workspace-harness: favicon + Folder/FolderGit2 + branch + harness (default)11 * - favicon-workspace-harness: favicon + Folder/FolderGit2 + branch + harness (default)
...@@ -84,7 +86,7 @@ export function ThreadCommandSubtitle(props: {...@@ -84,7 +86,7 @@ export function ThreadCommandSubtitle(props: {
84 {projectLabel ? <CommandPaletteMetaDot /> : null}86 {projectLabel ? <CommandPaletteMetaDot /> : null}
85 <span className="inline-flex min-w-0 items-center gap-1">87 <span className="inline-flex min-w-0 items-center gap-1">
86 <WorkspaceIcon variant={variant} isWorktree={isWorktree} />88 <WorkspaceIcon variant={variant} isWorktree={isWorktree} />
87 <span className="min-w-0 truncate">{branchLabel}</span>89 <MiddleTruncate value={branchLabel} />
88 </span>90 </span>
89 </>91 </>
90 ) : null}92 ) : null}
apps/web/src/components/chat/ChangedFilesTree.tsx+5-3
...@@ -19,6 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";...@@ -19,6 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
19import { PierreEntryIcon } from "./PierreEntryIcon";19import { PierreEntryIcon } from "./PierreEntryIcon";
20import { Button } from "../ui/button";20import { Button } from "../ui/button";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
22import { MiddleTruncate } from "../ui/middle-truncate";
2223
23const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};24const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
2425
...@@ -246,9 +247,10 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -246,9 +247,10 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
246 theme={resolvedTheme}247 theme={resolvedTheme}
247 className="size-3.5 text-muted-foreground/70"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 <MiddleTruncate
250 {node.name}251 value={node.name}
251 </span>252 className="font-mono text-xs text-foreground/85 group-hover:text-foreground"
253 />
252 {node.stat && (254 {node.stat && (
253 <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">255 <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">
254 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />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,6 +19,7 @@ import { useEffect, useState, type ReactNode } from "react";
1919
20import { Button } from "~/components/ui/button";20import { Button } from "~/components/ui/button";
21import { Spinner } from "~/components/ui/spinner";21import { Spinner } from "~/components/ui/spinner";
22import { MiddleTruncate } from "../ui/middle-truncate";
22import { observeVisibleAnimation } from "~/lib/visibleAnimation";23import { observeVisibleAnimation } from "~/lib/visibleAnimation";
23import { cn } from "~/lib/utils";24import { cn } from "~/lib/utils";
2425
...@@ -260,19 +261,25 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {...@@ -260,19 +261,25 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
260 {snapshot.branch ? (261 {snapshot.branch ? (
261 <>262 <>
262 <dt className="text-foreground/80">Branch</dt>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 ) : null}268 ) : null}
266 {snapshot.baseRef ? (269 {snapshot.baseRef ? (
267 <>270 <>
268 <dt className="text-foreground/80">Base</dt>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 ) : null}276 ) : null}
272 {snapshot.worktreePath ? (277 {snapshot.worktreePath ? (
273 <>278 <>
274 <dt className="text-foreground/80">Path</dt>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 ) : null}284 ) : null}
278 {snapshot.setupScript ? (285 {snapshot.setupScript ? (
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+16-5
...@@ -121,6 +121,7 @@ import {...@@ -121,6 +121,7 @@ import {
121import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";121import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
122import { toastManager } from "../ui/toast";122import { toastManager } from "../ui/toast";
123import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";123import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
124import { MiddleTruncate } from "../ui/middle-truncate";
124import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";125import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";
125import { PullRequestCopyableCode } from "./PullRequestCopyableCode";126import { PullRequestCopyableCode } from "./PullRequestCopyableCode";
126import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";127import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
...@@ -2267,7 +2268,9 @@ export function PullRequestDetailPanel({...@@ -2267,7 +2268,9 @@ export function PullRequestDetailPanel({
2267 className="size-3 shrink-0"2268 className="size-3 shrink-0"
2268 />2269 />
2269 ) : null}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 </PullRequestBaseFreshnessWarning>2274 </PullRequestBaseFreshnessWarning>
2272 ) : (2275 ) : (
2273 <Tooltip>2276 <Tooltip>
...@@ -2280,7 +2283,9 @@ export function PullRequestDetailPanel({...@@ -2280,7 +2283,9 @@ export function PullRequestDetailPanel({
2280 className="size-3 shrink-0"2283 className="size-3 shrink-0"
2281 />2284 />
2282 ) : null}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 </span>2289 </span>
2285 }2290 }
2286 />2291 />
...@@ -2298,7 +2303,9 @@ export function PullRequestDetailPanel({...@@ -2298,7 +2303,9 @@ export function PullRequestDetailPanel({
2298 <Tooltip>2303 <Tooltip>
2299 <TooltipTrigger2304 <TooltipTrigger
2300 render={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 <TooltipPopup side="top">{detail.headBranch}</TooltipPopup>2311 <TooltipPopup side="top">{detail.headBranch}</TooltipPopup>
...@@ -2452,7 +2459,9 @@ export function PullRequestDetailPanel({...@@ -2452,7 +2459,9 @@ export function PullRequestDetailPanel({
2452 className="size-3 shrink-0"2459 className="size-3 shrink-0"
2453 />2460 />
2454 ) : null}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 </PullRequestBaseFreshnessWarning>2465 </PullRequestBaseFreshnessWarning>
2457 ) : (2466 ) : (
2458 <Tooltip>2467 <Tooltip>
...@@ -2465,7 +2474,9 @@ export function PullRequestDetailPanel({...@@ -2465,7 +2474,9 @@ export function PullRequestDetailPanel({
2465 className="size-3 shrink-0"2474 className="size-3 shrink-0"
2466 />2475 />
2467 ) : null}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 </span>2480 </span>
2470 }2481 }
2471 />2482 />
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+4-1
...@@ -27,6 +27,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat";...@@ -27,6 +27,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat";
2727
28import { Button, InlineButton } from "../ui/button";28import { Button, InlineButton } from "../ui/button";
29import { Toggle, ToggleGroup } from "../ui/toggle-group";29import { Toggle, ToggleGroup } from "../ui/toggle-group";
30import { MiddleTruncate } from "../ui/middle-truncate";
30import { PullRequestCopyableCode } from "./PullRequestCopyableCode";31import { PullRequestCopyableCode } from "./PullRequestCopyableCode";
31import {32import {
32 PullRequestActorLabel,33 PullRequestActorLabel,
...@@ -252,7 +253,9 @@ export function PullRequestDetailGhost({...@@ -252,7 +253,9 @@ export function PullRequestDetailGhost({
252 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">253 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
253 {seed ? (254 {seed ? (
254 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">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 </span>259 </span>
257 ) : (260 ) : (
258 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">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,6 +8,7 @@ import type { ReactNode } from "react";
8import { cn } from "~/lib/utils";8import { cn } from "~/lib/utils";
9import { formatRelativeTimeLabel } from "~/timestampFormat";9import { formatRelativeTimeLabel } from "~/timestampFormat";
1010
11import { MiddleTruncate } from "../ui/middle-truncate";
11import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";12import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
12import {13import {
13 PullRequestActorAvatar,14 PullRequestActorAvatar,
...@@ -148,11 +149,17 @@ export function PullRequestRowAuthor({...@@ -148,11 +149,17 @@ export function PullRequestRowAuthor({
148 );149 );
149}150}
150151
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 */
152export function PullRequestRowBranches({ head, base }: { head: string; base: string }) {157export function PullRequestRowBranches({ head, base }: { head: string; base: string }) {
153 return (158 return (
154 <span className="truncate font-mono">159 <span className="flex min-w-0 items-center gap-1 font-mono">
155 {head} → {base}160 <MiddleTruncate value={head} />
161 <span className="shrink-0">→</span>
162 <MiddleTruncate value={base} className="max-w-[45%] shrink-0" />
156 </span>163 </span>
157 );164 );
158}165}
apps/web/src/components/ui/middle-truncate.tsx created+73
...@@ -0,0 +1,73 @@
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 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
47const DEFAULT_TAIL = 10;
48const 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 */
55export 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}