diff --git a/.changeset/responsive-menu-labels.md b/.changeset/responsive-menu-labels.md new file mode 100644 index 000000000..847dcc93c --- /dev/null +++ b/.changeset/responsive-menu-labels.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Compact menu-bar labels before truncating the current review title on narrow terminals. diff --git a/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index d709866ca..464ae4a01 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -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"; @@ -1272,7 +1273,6 @@ export function App({ activeMenuEntries, activeMenuId, activeMenuItemIndex, - activeMenuSpec, activeMenuWidth, activateCurrentMenuItem, closeMenu, @@ -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); diff --git a/packages/hunk/src/ui/AppHost.responsive.test.tsx b/packages/hunk/src/ui/AppHost.responsive.test.tsx index f0bfc7ccf..664abfa75 100644 --- a/packages/hunk/src/ui/AppHost.responsive.test.tsx +++ b/packages/hunk/src/ui/AppHost.responsive.test.tsx @@ -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(, { width: 180, diff --git a/packages/hunk/src/ui/components/chrome/MenuBar.tsx b/packages/hunk/src/ui/components/chrome/MenuBar.tsx index fed171f30..2e312ee95 100644 --- a/packages/hunk/src/ui/components/chrome/MenuBar.tsx +++ b/packages/hunk/src/ui/components/chrome/MenuBar.tsx @@ -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); diff --git a/packages/hunk/src/ui/components/chrome/menu.ts b/packages/hunk/src/ui/components/chrome/menu.ts index 3191fa429..de3ffa17e 100644 --- a/packages/hunk/src/ui/components/chrome/menu.ts +++ b/packages/hunk/src/ui/components/chrome/menu.ts @@ -44,6 +44,12 @@ const MENU_LABELS: Record = { help: "Help", }; +const COMPACT_MENU_LABELS: Partial> = { + 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. */ @@ -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((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, @@ -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) { diff --git a/packages/hunk/src/ui/lib/ui-lib.test.ts b/packages/hunk/src/ui/lib/ui-lib.test.ts index 657183955..91766cd5c 100644 --- a/packages/hunk/src/ui/lib/ui-lib.test.ts +++ b/packages/hunk/src/ui/lib/ui-lib.test.ts @@ -9,6 +9,7 @@ import { menuBoxHeight, menuWidth, nextMenuItemIndex, + responsiveActiveMenuSpec, responsiveMenuSpecs, type MenuEntry, } from "../components/chrome/menu"; @@ -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] }; diff --git a/packages/hunk/src/ui/log/LogApp.tsx b/packages/hunk/src/ui/log/LogApp.tsx index e7786c201..b2c8b0424 100644 --- a/packages/hunk/src/ui/log/LogApp.tsx +++ b/packages/hunk/src/ui/log/LogApp.tsx @@ -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"; @@ -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 ( { if (menu.activeMenuId) menu.openMenu(id); }} @@ -812,12 +822,12 @@ export function LogApp({ {statusHint ? {statusHint} : null} - {menu.activeMenuId && menu.activeMenuSpec ? ( + {menu.activeMenuId && activeMenuSpec ? ( { }); 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( diff --git a/test/session/broker-e2e.test.ts b/test/session/broker-e2e.test.ts index 1ca8deacb..eef8af0f2 100644 --- a/test/session/broker-e2e.test.ts +++ b/test/session/broker-e2e.test.ts @@ -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);