diff --git a/.changeset/header-breadcrumb-overflow.md b/.changeset/header-breadcrumb-overflow.md new file mode 100644 index 0000000..48f972a --- /dev/null +++ b/.changeset/header-breadcrumb-overflow.md @@ -0,0 +1,5 @@ +--- +"playground-cli": patch +--- + +Fix the deploy/TUI header breadcrumb garbling when a long domain overflows the row: the command no longer gets clipped ("playground deplo"), the domain keeps its ".dot" suffix, and the version label keeps a gap instead of gluing onto the network name. The header now degrades gracefully (narrower separators, then middle-truncation that preserves the ".dot" suffix) instead of letting the layout engine shrink every piece. diff --git a/src/utils/ui/theme/Header.tsx b/src/utils/ui/theme/Header.tsx index 3b868a8..1659ed0 100644 --- a/src/utils/ui/theme/Header.tsx +++ b/src/utils/ui/theme/Header.tsx @@ -16,6 +16,7 @@ import React, { useEffect } from "react"; import { Box, Text, useStdout } from "ink"; import { LAYOUT } from "./tokens.js"; +import { layoutHeader } from "./headerLayout.js"; import { setWindowTitle } from "./window-title.js"; import { Rule } from "./Rule.js"; @@ -59,9 +60,19 @@ export function Header({ cmd, subtitle, network, username, right, tabTitle }: He setWindowTitle(title); }, [cmd, subtitle, tabTitle]); - const pieces = [cmd, subtitle, network, username].filter((p): p is string => Boolean(p)); const cols = stdout?.columns ?? 80; const width = Math.max(10, Math.min(cols - LAYOUT.leftMargin * 2, LAYOUT.ruleWidthMax)); + // Pre-compute the breadcrumb so the left side ALWAYS fits within the row. + // If it didn't, yoga would distribute the shrink across every Text node — + // clipping the cmd, eating the domain's ".dot" tail, and collapsing the + // flexGrow gap so the version label glues onto the network piece. The + // paddingLeft sits INSIDE the row's width (yoga is border-box), so the + // text actually gets width - leftMargin columns. + const { pieces, separator } = layoutHeader( + { cmd, subtitle, network, username }, + right, + width - LAYOUT.leftMargin, + ); return ( // marginTop guarantees a blank line above the banner even when a @@ -73,16 +84,13 @@ export function Header({ cmd, subtitle, network, username, right, tabTitle }: He {pieces.map((piece, i) => ( - {i > 0 && {" · "}} + {i > 0 && {separator}} {/* - * wrap="truncate-end" so that an unexpectedly long - * breadcrumb (e.g. a 30-char registry username on - * a narrow terminal) clips with `…` instead of - * wrapping each piece into garbage like - * `dot ini` / `t`. The username is the realistic - * worst-case piece — `validateUsernameClient` - * caps it at 30 chars on the input path, but we - * still defend the header. + * wrap="truncate-end" is a last-resort backstop + * for pathologically narrow terminals where even + * layoutHeader's truncation floors overflow — + * normally the pieces are pre-fitted and yoga + * never has to shrink anything. */} 0} wrap="truncate-end"> {piece} diff --git a/src/utils/ui/theme/headerLayout.test.ts b/src/utils/ui/theme/headerLayout.test.ts new file mode 100644 index 0000000..93eceb6 --- /dev/null +++ b/src/utils/ui/theme/headerLayout.test.ts @@ -0,0 +1,152 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, expect, it } from "vitest"; +import { layoutHeader, RIGHT_GAP_MIN } from "./headerLayout.js"; + +/** Total columns the left breadcrumb occupies once joined with `separator`. */ +function leftWidth(layout: ReturnType): number { + return layout.pieces.join(layout.separator).length; +} + +describe("layoutHeader", () => { + it("keeps the wide separator when everything fits", () => { + const layout = layoutHeader( + { cmd: "playground deploy", subtitle: "myapp.dot", network: "paseo next v2" }, + "v0.28.5", + 72, + ); + expect(layout.separator).toBe(" · "); + expect(layout.pieces).toEqual(["playground deploy", "myapp.dot", "paseo next v2"]); + }); + + // Regression: the exact overflow from the field. Row cap 72 minus the + // 2-col left padding = 70 effective, long domain. Yoga used to shrink + // every Text node, rendering + // "playground deplo · devsignerutkplayground.do · paseo next v2v0.28.5" + // (clipped cmd, domain missing the final "t" of ".dot", version glued to + // the network label). Narrowing the separator + gap must fit it losslessly. + it("narrows the separator instead of clipping pieces (field repro)", () => { + const layout = layoutHeader( + { + cmd: "playground deploy", + subtitle: "devsignerutkplayground.dot", + network: "paseo next v2", + }, + "v0.28.5", + 70, + ); + expect(layout.pieces).toEqual([ + "playground deploy", + "devsignerutkplayground.dot", + "paseo next v2", + ]); + expect(layout.separator).toBe(" · "); + // The version label must keep at least the tight gap. + expect(leftWidth(layout)).toBeLessThanOrEqual(70 - "v0.28.5".length - RIGHT_GAP_MIN); + }); + + it("middle-truncates the subtitle so the .dot suffix survives", () => { + const layout = layoutHeader( + { + cmd: "playground deploy", + subtitle: "a-very-long-domain-name-indeed-yes.dot", + network: "paseo next v2", + }, + "v0.28.5", + 60, + ); + const [cmd, subtitle, network] = layout.pieces; + expect(cmd).toBe("playground deploy"); + expect(network).toBe("paseo next v2"); + expect(subtitle).toContain("…"); + expect(subtitle?.endsWith(".dot")).toBe(true); + expect(leftWidth(layout)).toBeLessThanOrEqual(60 - "v0.28.5".length - RIGHT_GAP_MIN); + }); + + it("sacrifices the username before the subtitle", () => { + const layout = layoutHeader( + { + cmd: "playground deploy", + subtitle: "myapp.dot", + network: "paseo next v2", + username: "a-thirty-character-username-xx", + }, + "v0.28.5", + 72, + ); + const [, subtitle, , username] = layout.pieces; + expect(subtitle).toBe("myapp.dot"); + expect(username).toContain("…"); + expect(leftWidth(layout)).toBeLessThanOrEqual(72 - "v0.28.5".length - RIGHT_GAP_MIN); + }); + + it("uses the full width when there is no right label", () => { + const layout = layoutHeader( + { + cmd: "playground deploy", + subtitle: "devsignerutkplayground.dot", + network: "paseo next v2", + }, + undefined, + 72, + ); + // 66 cols of content fits in 72 without any squeeze. + expect(layout.separator).toBe(" · "); + expect(layout.pieces[1]).toBe("devsignerutkplayground.dot"); + }); + + it("keeps the gap even when both username and subtitle hit the floor", () => { + const layout = layoutHeader( + { + cmd: "playground deploy", + subtitle: "a-very-long-domain-name-indeed-yes.dot", + network: "paseo next v2", + username: "a-thirty-character-username-xx", + }, + "v0.28.5", + 70, + ); + // Previously the MIN_PIECE floor left 1 col of overflow and yoga glued + // the version onto the username. The below-floor pass must absorb it. + expect(leftWidth(layout)).toBeLessThanOrEqual(70 - "v0.28.5".length - RIGHT_GAP_MIN); + expect(layout.pieces[0]).toBe("playground deploy"); + expect(layout.pieces[2]).toBe("paseo next v2"); + }); + + it("truncates below the floor before clipping cmd (56-col terminal)", () => { + const layout = layoutHeader( + { + cmd: "playground deploy", + subtitle: "devsignerutkplayground.dot", + network: "paseo next v2", + }, + "v0.28.5", + 54, + ); + expect(layout.pieces[0]).toBe("playground deploy"); + expect(layout.pieces[1]?.endsWith(".dot")).toBe(true); + expect(leftWidth(layout)).toBeLessThanOrEqual(54 - "v0.28.5".length - RIGHT_GAP_MIN); + }); + + it("never truncates cmd, even at hopeless widths", () => { + const layout = layoutHeader( + { cmd: "playground deploy", subtitle: "some-domain.dot", network: "paseo next v2" }, + "v0.28.5", + 30, + ); + expect(layout.pieces[0]).toBe("playground deploy"); + }); +}); diff --git a/src/utils/ui/theme/headerLayout.ts b/src/utils/ui/theme/headerLayout.ts new file mode 100644 index 0000000..87a2522 --- /dev/null +++ b/src/utils/ui/theme/headerLayout.ts @@ -0,0 +1,117 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Pure layout math for the Header breadcrumb. + * + * Why this exists: the header row is width-capped (LAYOUT.ruleWidthMax), and + * a long domain pushes the content past the cap. When that happened, yoga + * distributed the shrink across EVERY Text node, producing garbage like + * `playground deplo · devsignerutkplayground.do · paseo next v2v0.28.5` + * — a clipped command, a domain missing the final "t" of ".dot", and the + * version label glued to the network with no gap. Pre-computing the layout + * here guarantees the left side always fits, so yoga never shrinks anything. + * + * Degradation order, mildest first: + * 1. narrow the piece separator from " · " to " · " + * 2. shrink the gap before the right label from 2 spaces to 1 + * 3. middle-truncate the username, then the subtitle/domain, down to a + * legible floor (middle-truncation keeps the ".dot" suffix visible) + * 4. truncate them below the floor if the row is still too tight + * 5. drop the username, then the subtitle, entirely + * The cmd and network labels are never cut — they're short and fixed. + */ + +export const SEPARATOR_WIDE = " · "; +export const SEPARATOR_NARROW = " · "; +/** Preferred gap between the breadcrumb and the right-aligned label. */ +export const RIGHT_GAP = 2; +/** Tightest acceptable gap — losing a domain char to widen the gap is worse. */ +export const RIGHT_GAP_MIN = 1; +const ELLIPSIS = "…"; +/** Don't shrink a piece below this — an 11-char stub plus "…" stays legible. */ +const MIN_PIECE = 12; +/** Hard floor for the below-MIN_PIECE emergency pass. */ +const ABS_MIN_PIECE = 5; + +export interface HeaderParts { + cmd: string; + subtitle?: string; + network?: string; + username?: string; +} + +export interface HeaderLayout { + /** Breadcrumb pieces, possibly truncated, in render order. */ + pieces: string[]; + /** Separator to join them with. */ + separator: string; +} + +export function layoutHeader( + parts: HeaderParts, + right: string | undefined, + width: number, +): HeaderLayout { + const budgetAt = (gap: number) => Math.max(0, width - (right ? right.length + gap : 0)); + + const piecesOf = (p: HeaderParts) => + [p.cmd, p.subtitle, p.network, p.username].filter((v): v is string => Boolean(v)); + const widthOf = (p: HeaderParts, separator: string) => piecesOf(p).join(separator).length; + + const comfortable = budgetAt(RIGHT_GAP); + if (widthOf(parts, SEPARATOR_WIDE) <= comfortable) + return { pieces: piecesOf(parts), separator: SEPARATOR_WIDE }; + if (widthOf(parts, SEPARATOR_NARROW) <= comfortable) + return { pieces: piecesOf(parts), separator: SEPARATOR_NARROW }; + + const tight = budgetAt(RIGHT_GAP_MIN); + const current: HeaderParts = { ...parts }; + // Username first (least load-bearing), then the subtitle/domain. Two + // truncation passes: down to the legible floor, then — only if the row is + // still too tight — below it. + const shrinkable: Array<"username" | "subtitle"> = ["username", "subtitle"]; + for (const floor of [MIN_PIECE, ABS_MIN_PIECE]) { + for (const key of shrinkable) { + const value = current[key]; + if (!value) continue; + const overflow = widthOf(current, SEPARATOR_NARROW) - tight; + if (overflow <= 0) break; + current[key] = truncateMiddle(value, Math.max(floor, value.length - overflow)); + } + } + // Last resort: drop the squeezable pieces entirely. + for (const key of shrinkable) { + if (widthOf(current, SEPARATOR_NARROW) <= tight) break; + current[key] = undefined; + } + + // If a pathologically narrow terminal STILL overflows (cmd + network + // alone don't fit), the Header's wrap="truncate-end" backstop clips the + // remainder at render time. + return { pieces: piecesOf(current), separator: SEPARATOR_NARROW }; +} + +/** + * `devsigner-very-long-name.dot` → `devsign…ame.dot`: keeps both ends, so a + * domain's ".dot" suffix stays visible no matter how hard we squeeze. + */ +function truncateMiddle(value: string, max: number): string { + if (value.length <= max) return value; + const keep = max - ELLIPSIS.length; + const front = Math.ceil(keep / 2); + const back = keep - front; + return value.slice(0, front) + ELLIPSIS + value.slice(value.length - back); +}