Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/header-breadcrumb-overflow.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 18 additions & 10 deletions src/utils/ui/theme/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand All @@ -73,16 +84,13 @@ export function Header({ cmd, subtitle, network, username, right, tabTitle }: He
<Box flexGrow={1} flexDirection="row">
{pieces.map((piece, i) => (
<React.Fragment key={i}>
{i > 0 && <Text dimColor>{" · "}</Text>}
{i > 0 && <Text dimColor>{separator}</Text>}
{/*
* 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.
*/}
<Text bold={i === 0} dimColor={i > 0} wrap="truncate-end">
{piece}
Expand Down
152 changes: 152 additions & 0 deletions src/utils/ui/theme/headerLayout.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof layoutHeader>): 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");
});
});
117 changes: 117 additions & 0 deletions src/utils/ui/theme/headerLayout.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading