Skip to content
Open
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/responsive-menu-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Compact menu-bar labels before truncating the current review title on narrow terminals.
6 changes: 5 additions & 1 deletion packages/hunk/src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type { ReloadedSessionResult, ReloadSessionOptions } from "../session/typ
import { HelpDialog } from "./components/chrome/HelpDialog";
import { MenuDropdown } from "./components/chrome/MenuDropdown";
import { MenuBar } from "./components/chrome/MenuBar";
import { responsiveActiveMenuSpec, responsiveMenuSpecs } from "./components/chrome/menu";
import { ConfirmDialog, confirmDialogHeight } from "./components/chrome/ConfirmDialog";
import { ExtensionDialog } from "./components/chrome/ExtensionDialog";
import { ViewPreferenceQuitDialog } from "./components/chrome/ViewPreferenceQuitDialog";
Expand Down Expand Up @@ -1272,7 +1273,6 @@ export function App({
activeMenuEntries,
activeMenuId,
activeMenuItemIndex,
activeMenuSpec,
activeMenuWidth,
activateCurrentMenuItem,
closeMenu,
Expand Down Expand Up @@ -1337,6 +1337,10 @@ export function App({
0,
);
const topTitle = `${bootstrap.changeset.title} ${changedFileCount} ${changedFileLabel} +${totalAdditions} -${totalDeletions}`;
const responsiveMenuLayout = responsiveMenuSpecs(menuSpecs, terminal.width, topTitle);
const activeMenuSpec = activeMenuId
? responsiveActiveMenuSpec(responsiveMenuLayout, activeMenuId)
: undefined;
const diffHeaderStatsWidth = maxFileHeaderStatsWidth(filteredFiles);
const diffHeaderLabelWidth = Math.max(0, diffContentWidth - diffHeaderStatsWidth - 1);
const diffSeparatorWidth = Math.max(0, diffContentWidth - 2);
Expand Down
8 changes: 8 additions & 0 deletions packages/hunk/src/ui/AppHost.responsive.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ describe("responsive app", () => {
expect(frame).not.toContain("packages/visual-studio-code-.");
});

test("the menu bar compacts labels before truncating the review title", async () => {
const frame = await captureFrameForBootstrap(createBootstrap(), 70, 12);

expect(frame).toContain("File View Nav Agent ?");
expect(frame).toContain("repo working tree 2 files +3 -2");
expect(frame).not.toContain("Navigate");
});

test("View menu sidebar checkmark follows actual medium-viewport visibility", async () => {
const setup = await testRender(<AppHost bootstrap={createBootstrap("auto")} />, {
width: 180,
Expand Down
2 changes: 1 addition & 1 deletion packages/hunk/src/ui/components/chrome/MenuBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function MenuBar({
onHoverMenu: (menuId: MenuId) => void;
onToggleMenu: (menuId: MenuId) => void;
}) {
const responsive = responsiveMenuSpecs(menuSpecs, terminalWidth);
const responsive = responsiveMenuSpecs(menuSpecs, terminalWidth, topTitle);
const visibleMenuSpecs = responsive.visible;
const hiddenMenuIds = new Set(responsive.hidden.map((menu) => menu.id));
const activeHiddenIndex = responsive.hidden.findIndex((menu) => menu.id === activeMenuId);
Expand Down
62 changes: 56 additions & 6 deletions packages/hunk/src/ui/components/chrome/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ const MENU_LABELS: Record<MenuId, string> = {
help: "Help",
};

const COMPACT_MENU_LABELS: Partial<Record<MenuId, string>> = {
navigate: "Nav",
extensions: "Ext",
help: "?",
};

export const MENU_ORDER = Object.keys(MENU_LABELS) as MenuId[];

/** The entries of one menu, or none when the session does not show it. */
Expand Down Expand Up @@ -77,18 +83,48 @@ export function buildMenuSpecs(menus: AppMenus) {
);
}

/** Fit a shared ordered menu model into one bar and retain hidden menus behind overflow. */
export function responsiveMenuSpecs(menuSpecs: readonly MenuSpec[], terminalWidth: number) {
/** Reflow menu positions using compact labels where they provide meaningful space. */
function compactMenuSpecs(menuSpecs: readonly MenuSpec[]) {
return menuSpecs.reduce<MenuSpec[]>((items, menu) => {
const previous = items.at(-1);
const label = COMPACT_MENU_LABELS[menu.id] ?? menu.label;
items.push({
...menu,
left: previous ? previous.left + previous.width : 1,
width: label.length + 2,
label,
});
return items;
}, []);
}

export interface ResponsiveMenuLayout {
visible: MenuSpec[];
hidden: MenuSpec[];
overflowLeft: number | null;
}

/** Fit menus beside the title, compacting labels before retaining hidden menus behind overflow. */
export function responsiveMenuSpecs(
menuSpecs: readonly MenuSpec[],
terminalWidth: number,
topTitle = "",
): ResponsiveMenuLayout {
const rightEdge = Math.max(1, terminalWidth - 1);
const allVisible = menuSpecs.filter((menu) => menu.left + menu.width <= rightEdge);
if (allVisible.length === menuSpecs.length) {
const fullMenusFit = menuSpecs.every((menu) => menu.left + menu.width <= rightEdge);
const titleFits = measureTextWidth(topTitle) <= menuBarTitleWidth(menuSpecs, terminalWidth);
const displaySpecs = fullMenusFit && titleFits ? [...menuSpecs] : compactMenuSpecs(menuSpecs);
const allVisible = displaySpecs.filter((menu) => menu.left + menu.width <= rightEdge);
if (allVisible.length === displaySpecs.length) {
return { visible: allVisible, hidden: [] as MenuSpec[], overflowLeft: null };
}

const overflowWidth = 3;
const visible = menuSpecs.filter((menu) => menu.left + menu.width + overflowWidth <= rightEdge);
const visible = displaySpecs.filter(
(menu) => menu.left + menu.width + overflowWidth <= rightEdge,
);
const visibleIds = new Set(visible.map((menu) => menu.id));
const hidden = menuSpecs.filter((menu) => !visibleIds.has(menu.id));
const hidden = displaySpecs.filter((menu) => !visibleIds.has(menu.id));
const previous = visible.at(-1);
return {
visible,
Expand All @@ -97,6 +133,20 @@ export function responsiveMenuSpecs(menuSpecs: readonly MenuSpec[], terminalWidt
};
}

/** Position an open dropdown beneath its visible label or the overflow control that represents it. */
export function responsiveActiveMenuSpec(
layout: ResponsiveMenuLayout,
activeMenuId: MenuId,
): MenuSpec | undefined {
const visible = layout.visible.find((menu) => menu.id === activeMenuId);
if (visible) return visible;

const hidden = layout.hidden.find((menu) => menu.id === activeMenuId);
return hidden && layout.overflowLeft !== null
? { ...hidden, left: layout.overflowLeft, width: 3 }
: hidden;
}

/** Find the next selectable menu item, skipping separators. */
export function nextMenuItemIndex(entries: MenuEntry[], currentIndex: number, delta: number) {
if (entries.length === 0) {
Expand Down
59 changes: 59 additions & 0 deletions packages/hunk/src/ui/lib/ui-lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
menuBoxHeight,
menuWidth,
nextMenuItemIndex,
responsiveActiveMenuSpec,
responsiveMenuSpecs,
type MenuEntry,
} from "../components/chrome/menu";
Expand Down Expand Up @@ -187,6 +188,64 @@ describe("ui helpers", () => {
expect(layout.overflowLeft).toBe(13);
});

test("responsive menus compact labels before truncating the changeset title", () => {
const item: MenuEntry = { kind: "item", label: "One", action: () => {} };
const specs = buildMenuSpecs({
file: [item],
view: [item],
navigate: [item],
agent: [item],
extensions: [item],
help: [item],
});
const title = "repo working tree 2 files +3 -2";

const compact = responsiveMenuSpecs(specs, 80, title);
expect(compact.visible.map(({ label }) => label)).toEqual([
"File",
"View",
"Nav",
"Agent",
"Ext",
"?",
]);
expect(compact.hidden).toEqual([]);
expect(menuBarTitleWidth(compact.visible, 80)).toBeGreaterThanOrEqual(measureTextWidth(title));

const full = responsiveMenuSpecs(specs, 100, title);
expect(full.visible.map(({ label }) => label)).toEqual([
"File",
"View",
"Navigate",
"Agent",
"Extensions",
"Help",
]);
});

test("responsive dropdowns follow compact labels and hidden-menu overflow", () => {
const item: MenuEntry = { kind: "item", label: "One", action: () => {} };
const specs = buildMenuSpecs({
file: [item],
view: [item],
navigate: [item],
commit: [item],
help: [item],
});

const compact = responsiveMenuSpecs(specs, 36, "history");
expect(responsiveActiveMenuSpec(compact, "navigate")).toMatchObject({
label: "Nav",
left: 13,
});

const overflow = responsiveMenuSpecs(specs, 20, "history");
expect(responsiveActiveMenuSpec(overflow, "commit")).toMatchObject({
left: overflow.overflowLeft,
width: 3,
});
});

test("menuBarTitleWidth cedes title space to the menus the bar shows", () => {
const item: MenuEntry = { kind: "item", label: "One", action: () => {} };
const base = { file: [item], view: [item], navigate: [item], agent: [item], help: [item] };
Expand Down
18 changes: 14 additions & 4 deletions packages/hunk/src/ui/log/LogApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import { resolveExtensionCommands, resolveExtensionSessionOptions } from "../../
import { HelpDialog } from "../components/chrome/HelpDialog";
import { MenuBar } from "../components/chrome/MenuBar";
import { MenuDropdown } from "../components/chrome/MenuDropdown";
import type { AppMenus, MenuEntry } from "../components/chrome/menu";
import {
responsiveActiveMenuSpec,
responsiveMenuSpecs,
type AppMenus,
type MenuEntry,
} from "../components/chrome/menu";
import { ThemeSelectorDialog } from "../components/chrome/ThemeSelectorDialog";
import { CommitMetadataText } from "../components/CommitMetadataText";
import { RevisionIdControl } from "../components/RevisionIdControl";
Expand Down Expand Up @@ -572,6 +577,11 @@ export function LogApp({
1,
terminal.width - measureTextWidth(statusHint) - (statusHint ? 3 : 2),
);
const topTitle = `${sanitizeTerminalLine(basename(runtime.repoRoot))} · ${sanitizeTerminalLine(runtime.providerName)} history`;
const responsiveMenuLayout = responsiveMenuSpecs(menu.menuSpecs, terminal.width, topTitle);
const activeMenuSpec = menu.activeMenuId
? responsiveActiveMenuSpec(responsiveMenuLayout, menu.activeMenuId)
: undefined;
return (
<box
style={{
Expand All @@ -586,7 +596,7 @@ export function LogApp({
menuSpecs={menu.menuSpecs}
terminalWidth={terminal.width}
theme={theme}
topTitle={`${sanitizeTerminalLine(basename(runtime.repoRoot))} · ${sanitizeTerminalLine(runtime.providerName)} history`}
topTitle={topTitle}
onHoverMenu={(id) => {
if (menu.activeMenuId) menu.openMenu(id);
}}
Expand Down Expand Up @@ -812,12 +822,12 @@ export function LogApp({
</text>
{statusHint ? <text fg={theme.muted}>{statusHint}</text> : null}
</box>
{menu.activeMenuId && menu.activeMenuSpec ? (
{menu.activeMenuId && activeMenuSpec ? (
<MenuDropdown
activeMenuId={menu.activeMenuId}
activeMenuEntries={menu.activeMenuEntries}
activeMenuItemIndex={menu.activeMenuItemIndex}
activeMenuSpec={menu.activeMenuSpec}
activeMenuSpec={activeMenuSpec}
activeMenuWidth={menu.activeMenuWidth}
terminalHeight={terminal.height}
terminalWidth={terminal.width}
Expand Down
2 changes: 1 addition & 1 deletion test/pty/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ describe("PTY layout", () => {
});

try {
await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
await session.waitForText(/View\s+Nav\s+Agent\s+\?/, {
timeout: 15_000,
});
const snapshot = await harness.waitForSnapshot(
Expand Down
4 changes: 2 additions & 2 deletions test/session/broker-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,12 +773,12 @@ describe("session broker end-to-end", () => {
"rendered session after probing a conflicting broker listener",
(current) =>
conflictingRequestCount > 0 &&
current.includes("View Navigate Agent Help") &&
current.includes("View Nav Agent ?") &&
current.includes(fixture.afterName) &&
current.includes("export const gamma = true;"),
);
expect(conflictingRequestCount).toBeGreaterThan(0);
expect(transcript).toContain("View Navigate Agent Help");
expect(transcript).toContain("View Nav Agent ?");
expect(transcript).toContain(`${fixture.afterName}`);
expect(transcript).toContain("export const gamma = true;");
expect(await quitHunkSession(hunkProc, fixture)).toBe(0);
Expand Down
Loading