diff --git a/src/frontend/sidebar/tree/HostItem/HostItem.tsx b/src/frontend/sidebar/tree/HostItem/HostItem.tsx index 751213f..9c0403c 100644 --- a/src/frontend/sidebar/tree/HostItem/HostItem.tsx +++ b/src/frontend/sidebar/tree/HostItem/HostItem.tsx @@ -129,12 +129,6 @@ function canCopyHostSudoPassword(host: Host): boolean { return !!host.hasSudoPassword || !!host.sudoPassword; } -/** - * Per-density layout knobs. Every feature is available in both densities -- - * only spacing/sizing and whether a couple of rows collapse to a single line - * differ. Keeping this as one lookup (rather than two parallel JSX trees) - * means a future style tweak only has to be made once. - */ const DOUBLE_CLICK_WINDOW_MS = 250; const DEFAULT_ROW_FIELDS = defaultHostRowFields(); @@ -157,19 +151,18 @@ export function formatHostAddress( return `${user}${host.ip}${port}`; } +/** Per-density spacing and optional detail rows. */ const HOST_ITEM_DENSITY_TOKENS = { comfortable: { rowPadding: "pl-[8.75px] pr-[7px] py-[7px]", nameTextSize: "text-[13px]", showAddressRow: true, - showTagsRow: true, showResourceRow: true, }, compact: { rowPadding: "pl-[7px] pr-[5.25px] py-[5px]", nameTextSize: "text-[12px]", showAddressRow: false, - showTagsRow: false, showResourceRow: false, }, } as const; @@ -337,7 +330,6 @@ export function HostItem({ const tokens = { ...densityTokens, showAddressRow: densityTokens.showAddressRow && rowFields.showAddress, - showTagsRow: densityTokens.showTagsRow && showTags, showResourceRow: densityTokens.showResourceRow && showResourceBars, }; const isCompact = density === "compact"; @@ -904,7 +896,7 @@ export function HostItem({ // than its slot and the virtualizer spread the rows apart to match. The // negative margin cancels the gap while keeping the element in flow, so the // modes that animate open still have something to transition. - const trayCollapsedClass = `max-h-0 opacity-0 ${isCompact ? "" : "-mt-[3.5px]"}`; + const trayCollapsedClass = "max-h-0 opacity-0 -mt-[3.5px]"; const trayVisibilityClass = alwaysShowTray || actionsOnly ? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[130px] opacity-100" : trayCollapsedClass}` @@ -916,6 +908,53 @@ export function HostItem({ // the length of the animation. Both change together instead. `overflow-hidden ${hoverTrayOpen ? "max-h-[130px] opacity-100" : trayCollapsedClass}`; + const hostBadges = ( + <> + {rowFields.showBadges && + badges.map((badge) => { + const Badge = badge.component; + return ; + })} + {rowFields.showSharedBadge && host.isShared && ( + + + + + + {t("hosts.sharing.sharedBadge")} + + + + {t("hosts.sharing.sharedBadgeTooltip", { + owner: host.ownerUsername || "?", + level: t( + `hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`, + ), + })} + + + + )} + + ); + + const trayToggle = !selectionMode && (shouldUseClickTray || actionsOnly) && ( + + ); + return (
- - {buildStatusTooltip(host, availability, t)} + + {isCompact + ? [ + host.name, + formatHostAddress(host, { + showUsername: true, + showPort: true, + }), + host.tags?.join(", "), + buildStatusTooltip(host, availability, t), + ] + .filter(Boolean) + .join("\n") + : buildStatusTooltip(host, availability, t)} @@ -1164,91 +1220,68 @@ export function HostItem({ )} - {rowFields.showBadges && - badges.map((badge) => { - const Badge = badge.component; - return ; - })} - {rowFields.showSharedBadge && host.isShared && ( - - - - - - {t("hosts.sharing.sharedBadge")} - - - - {t("hosts.sharing.sharedBadgeTooltip", { - owner: host.ownerUsername || "?", - level: t( - `hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`, - ), - })} - - - - )} - {isCompact && - showTags && - host.tags?.slice(0, 2).map((tag) => ( - - {tag} - - ))} - {isCompact && showTags && (host.tags?.length ?? 0) > 2 && ( - - +{host.tags!.length - 2} - - )} - {isCompact && rowFields.showAddress && ( - - {formatHostAddress(host, { ...rowFields, showUsername: false })} + {!isCompact && hostBadges} + {!isCompact && trayToggle} +
+ + {isCompact && ( +
+ + {formatHostAddress(host, { showUsername: true, showPort: true })} + {host.tags?.length ? `, ${host.tags.join(", ")}` : ""} - )} - {isCompact && !selectionMode && ( -
*]:shrink-0 ${alwaysShowTray || actionsOnly || trayOpenState || hoverTrayOpen ? "" : "hidden"}`} - > - {connectionButtons} -
- {managementButtons} -
+
+ {hostBadges} + {rowFields.showAddress && ( + + {formatHostAddress(host, { + ...rowFields, + showUsername: false, + })} + + )} + {showTags && !!host.tags?.length && ( +
+ {host.tags.map((tag) => ( + + {tag} + + ))} +
+ )}
- )} - {!selectionMode && (shouldUseClickTray || actionsOnly) && ( - - )} -
+ {!selectionMode && ( +
+
*]:shrink-0 ${alwaysShowTray || actionsOnly || trayOpenState || hoverTrayOpen ? "" : "hidden"}`} + > + {connectionButtons} +
+ {managementButtons} +
+
+ {trayToggle} +
+ )} +
+ )} {tokens.showAddressRow && ( @@ -1258,10 +1291,8 @@ export function HostItem({ {/* Tag pills */} {showTags && !isCompact && host.tags && host.tags.length > 0 && ( -
- {host.tags.slice(0, isCompact ? 2 : 4).map((tag) => ( +
+ {host.tags.slice(0, 4).map((tag) => ( ))} - {host.tags.length > (isCompact ? 2 : 4) && ( + {host.tags.length > 4 && ( - +{host.tags.length - (isCompact ? 2 : 4)} + +{host.tags.length - 4} )}
@@ -1283,9 +1314,7 @@ export function HostItem({ (alwaysShowTray || actionsOnly || (shouldUseClickTray && isTrayOpen)) && ( -
+
{connectionButtons}
)} diff --git a/src/frontend/sidebar/tree/SidebarTree.tsx b/src/frontend/sidebar/tree/SidebarTree.tsx index 121cc84..be2439f 100644 --- a/src/frontend/sidebar/tree/SidebarTree.tsx +++ b/src/frontend/sidebar/tree/SidebarTree.tsx @@ -722,53 +722,32 @@ export function SidebarTree({ (trayTrigger === "click" || isTouchOnly); const isCompactDensity = density === "compact"; - // Fixed, exactly-computed row heights rather than estimate-then-measure: - // the redesigned rows have a small, known set of shapes (folder header, - // host closed, host with its tray open, in each density), so there is - // nothing left to discover at layout time. estimateSize doubled as the - // real size unconditionally used to cause visible gaps between rows -- - // ResizeObserver-based remeasuring raced the tray's open/close transition - // and could lock in a stale height for a row that happened to get measured - // mid-animation. A fixed lookup has no such window: every row's height is - // decided before the virtualizer ever asks. - // Re-measured live after fixing two padding bugs: (1) the comfortable- - // density row's top/bottom padding asymmetry (was pt-2.5 pb-2, now the - // symmetric py-2), and (2) redundant stacked top padding between the - // always-visible connection-buttons row and the management-buttons row - // (the tray wrapper's own pt-1.5 plus the management row's separate - // pt-1 mt-0.5 compounded into a much larger gap there than between any - // other pair of rows in the card). - // Click mode adds a size-5 expand-actions chevron to the name row, which is - // taller than anything hover mode puts there -- the base heights were - // measured in hover mode, so a click-mode row renders past its slot and its - // status stripe runs into the row below. Most visible down an indented - // sub-host group, where rows stack with no folder header between them. - // Measured off the rendered row (49.5 in a 45 slot). actionsOnly renders the - // same chevron but its own constants were measured with it, so it is not - // included here. + // Estimate the known row shapes up front; measureElement refines them when + // live content changes. Compact hosts always reserve both lines. + // Two 19px lines plus 5px top and 5px bottom padding. + const COMPACT_HOST_ROW_HEIGHT = 48; + // In comfortable density, click mode's 17.5px chevron adds 4.5px to the + // 13px name line. actionsOnly already includes it in its measured heights. const CLICK_CHEVRON_EXTRA = clickTrayActive ? 4.5 : 0; - const HOST_ROW_HEIGHT = (isCompactDensity ? 27.5 : 45) + CLICK_CHEVRON_EXTRA; + const HOST_ROW_HEIGHT = 45 + CLICK_CHEVRON_EXTRA; const FOLDER_ROW_HEIGHT = 31.5; // "always" mode permanently renders the connection-buttons row plus the // management row -- measured directly rather than derived, since it has its // own fixed shape. The resource bars are NOT included: they only render for // an online host with CPU/RAM, so rowHeight adds RESOURCE_ROW_EXTRA per row. - // Carries the click-mode chevron so the open row lands at its measured - // 104.75 rather than always mode's 100.25. The term cancels out of - // OPEN_TRAY_EXTRA below, leaving HOST_ROW_HEIGHT to supply it once. - const ALWAYS_ROW_HEIGHT = - (isCompactDensity ? 73.75 : 83) + CLICK_CHEVRON_EXTRA; + // The click-mode chevron term cancels out of OPEN_TRAY_EXTRA, leaving + // HOST_ROW_HEIGHT to supply it once. + const ALWAYS_ROW_HEIGHT = 83 + CLICK_CHEVRON_EXTRA; const OPEN_TRAY_EXTRA = ALWAYS_ROW_HEIGHT - HOST_ROW_HEIGHT; // "actionsOnly" permanently renders just the connection-buttons row above // the tray; the management row/resource bars stay collapsed until toggled. - const ACTIONS_ONLY_ROW_HEIGHT = isCompactDensity ? 50.25 : 75.75; + const ACTIONS_ONLY_ROW_HEIGHT = 75.75; // Opening the management row from actionsOnly's closed state (which // already includes the connection row). Excludes the resource bars for the // same reason as ALWAYS_ROW_HEIGHT -- rowHeight adds them per row. - const ACTIONS_ONLY_OPEN_ROW_HEIGHT = isCompactDensity ? 79.25 : 87.5; - // Tag pills are a separate flex row. Comfortable density adds the row plus - // its gap; compact density pulls it upward by 2px but still needs a slot. - const TAG_ROW_EXTRA = isCompactDensity ? 12.5 : 18.5; + const ACTIONS_ONLY_OPEN_ROW_HEIGHT = 87.5; + // Comfortable tag pills occupy a separate row plus its gap. + const TAG_ROW_EXTRA = 18.5; // The username@ip line plus its gap. const ADDRESS_ROW_SAVED = rowFields && !rowFields.showAddress ? 14.5 : 0; // showResourceBars:false simply drops RESOURCE_ROW_EXTRA from every row. @@ -778,7 +757,7 @@ export function SidebarTree({ const row = visibleRows[index]; if (!row) return FOLDER_ROW_HEIGHT; if (isFolder(row.item)) return FOLDER_ROW_HEIGHT; - if (isCompactDensity) return 29; + if (isCompactDensity) return COMPACT_HOST_ROW_HEIGHT; const tagRowExtra = showTags && row.item.tags?.length ? TAG_ROW_EXTRA : 0; // The resource bars only render for an online host that reported CPU/RAM. // Reserving their height unconditionally left a gap under every offline diff --git a/tests/frontend/sidebar/tree/HostItem/HostItem.test.tsx b/tests/frontend/sidebar/tree/HostItem/HostItem.test.tsx index 5970271..723de3d 100644 --- a/tests/frontend/sidebar/tree/HostItem/HostItem.test.tsx +++ b/tests/frontend/sidebar/tree/HostItem/HostItem.test.tsx @@ -6,16 +6,21 @@ import { render, screen, } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Host } from "@termix-ssh/plugin-sdk/ui"; import { registerHostAction } from "@termix-ssh/plugin-sdk/testing-host"; import { LOCAL_ADAPTIVE_PREFERENCES_KEY } from "@termix-ssh/plugin-sdk/testing-host"; -const { markTabSurfaceUsedMock, preloadTabSurfaceMock } = vi.hoisted(() => ({ - markTabSurfaceUsedMock: vi.fn(), - preloadTabSurfaceMock: vi.fn(), -})); +const { markTabSurfaceUsedMock, preloadTabSurfaceMock, hostBadgesMock } = + vi.hoisted(() => ({ + markTabSurfaceUsedMock: vi.fn(), + preloadTabSurfaceMock: vi.fn(), + hostBadgesMock: vi.fn< + () => ReturnType + >(() => []), + })); vi.mock("@termix-ssh/plugin-sdk/ui", async (importOriginal) => { const original = await importOriginal>(); @@ -26,6 +31,7 @@ vi.mock("@termix-ssh/plugin-sdk/ui", async (importOriginal) => { () => ({ markTabSurfaceUsed: markTabSurfaceUsedMock, preloadTabSurface: preloadTabSurfaceMock, + useHostBadges: hostBadgesMock, }), () => ({ useHostStatus: () => null, @@ -144,6 +150,7 @@ afterEach(() => { cleanup(); markTabSurfaceUsedMock.mockClear(); preloadTabSurfaceMock.mockClear(); + hostBadgesMock.mockReset().mockReturnValue([]); localStorage.removeItem(LOCAL_ADAPTIVE_PREFERENCES_KEY); }); @@ -268,11 +275,12 @@ describe("HostItem density parity", () => { expect(screen.getByText("prod")).toBeTruthy(); }); - it("puts compact tags on the name row instead of their own line", () => { + it("puts compact tags on the details row", () => { renderHostItem("compact"); const tag = screen.getByText("prod"); expect(tag.dataset.testid).toBe("host-inline-tag"); - expect(tag.closest("[data-drag-label]")).toBeTruthy(); + expect(tag.closest("[data-drag-label]")).toBeNull(); + expect(screen.getByTestId("host-details-row").contains(tag)).toBe(true); }); it("hides tags in both densities when showTags is false", () => { @@ -320,6 +328,226 @@ describe("HostItem density parity", () => { }); }); +describe("HostItem compact rows", () => { + it.each([false, true])( + "keeps metadata below the name with selection mode %s", + (selectionMode) => { + hostBadgesMock.mockReturnValue([ + { + id: "fixture-badge", + when: () => true, + component: () => Plugin badge, + }, + ]); + render( + , + ); + + const nameRow = screen + .getByText(baseHost.name) + .closest("[data-drag-label]")!; + const details = screen.getByTestId("host-details-row"); + expect(nameRow.textContent).toBe(baseHost.name); + expect(nameRow.querySelector(".lucide-pin")).toBeTruthy(); + expect(nameRow.querySelector(".lucide-lock")).toBeTruthy(); + expect(nameRow.contains(screen.getByTitle("hosts.expandSubHosts"))).toBe( + true, + ); + expect(!!nameRow.querySelector(".lucide-check")).toBe(selectionMode); + for (const text of [ + baseHost.ip, + "prod", + "Plugin badge", + "hosts.sharing.sharedBadge", + ]) { + const metadata = screen.getByText(text); + expect(details.contains(metadata)).toBe(true); + expect(nameRow.contains(metadata)).toBe(false); + } + expect(nameRow.querySelectorAll("button")).toHaveLength(1); + if (selectionMode) { + expect(screen.queryByTestId("host-inline-actions")).toBeNull(); + expect(screen.queryByTitle("hosts.expandActions")).toBeNull(); + } else { + expect( + details.contains(screen.getByTestId("host-inline-actions")), + ).toBe(true); + expect(details.contains(screen.getByTitle("hosts.expandActions"))).toBe( + true, + ); + } + }, + ); + + it.each(["hover", "click", "always", "actionsOnly"] as const)( + "preserves compact action visibility in %s mode", + (trayTrigger) => { + const props = { + host: baseHost, + onOpenTab: noop, + onEditHost: noop, + onDelete: noop, + onDuplicate: noop, + density: "compact" as const, + trayTrigger, + }; + const { rerender } = render(); + const actions = () => screen.getByTestId("host-inline-actions"); + const management = () => + screen.getByTitle("hosts.editHostAction").parentElement!; + const expectVisibility = ( + visible: boolean, + managementVisible: boolean, + ) => { + expect(actions().classList.contains("hidden")).toBe(!visible); + expect(management().classList.contains("hidden")).toBe( + !managementVisible, + ); + expect(screen.getByText(baseHost.ip).closest(".hidden")).toBeNull(); + }; + + expectVisibility( + trayTrigger === "always" || trayTrigger === "actionsOnly", + trayTrigger === "always", + ); + rerender(); + expectVisibility( + trayTrigger !== "click", + trayTrigger === "always" || trayTrigger === "hover", + ); + rerender(); + expectVisibility(true, true); + rerender(); + expectVisibility(true, true); + rerender(); + expect(screen.queryByTestId("host-inline-actions")).toBeNull(); + expect(screen.getByText(baseHost.ip).closest(".hidden")).toBeNull(); + }, + ); + + it("toggles second-row actions and runs them without launching the host row", () => { + const Icon = (() => null) as never; + const dispose = registerHostAction({ + id: "compact-terminal", + titleKey: "fixture.compactTerminal", + icon: Icon, + kind: "connect", + tabType: "terminal", + when: () => true, + }); + const onOpenTab = vi.fn(); + const onEditHost = vi.fn(); + try { + function Harness() { + const [isTrayOpen, setTrayOpen] = useState(false); + return ( + + ); + } + render(); + const toggle = screen.getByRole("button", { + name: "hosts.expandActions", + expanded: false, + }); + expect(toggle.getAttribute("type")).toBe("button"); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + expect(onOpenTab).not.toHaveBeenCalled(); + expect( + screen.getByTestId("host-inline-actions").classList.contains("hidden"), + ).toBe(false); + fireEvent.click(screen.getByTitle("hosts.editHostAction")); + expect(onEditHost).toHaveBeenCalledOnce(); + expect(onOpenTab).not.toHaveBeenCalled(); + fireEvent.click(screen.getByTitle("fixture.compactTerminal")); + expect(onOpenTab).toHaveBeenCalledExactlyOnceWith("terminal", { + forceNewTab: true, + }); + fireEvent.click(screen.getByTitle("hosts.collapseActions")); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect( + screen.getByTestId("host-inline-actions").classList.contains("hidden"), + ).toBe(true); + expect(onOpenTab).toHaveBeenCalledOnce(); + } finally { + act(() => dispose()); + } + }); + + it("shows the full compact name, address, tags and status in the name tooltip", async () => { + const host = { + ...baseHost, + tags: ["prod", "web", "all-tags-still-accessible"], + }; + render( + , + ); + expect(screen.queryByText("prod")).toBeNull(); + const accessibleDetails = screen.getByText( + "root@10.0.0.5:22, prod, web, all-tags-still-accessible", + ); + expect(accessibleDetails.classList.contains("sr-only")).toBe(true); + expect( + screen.getByTestId("host-details-row").contains(accessibleDetails), + ).toBe(true); + await userEvent.hover(screen.getByText(host.name)); + const tooltip = await screen.findByRole("tooltip"); + for (const text of [ + host.name, + "root@10.0.0.5:22", + ...host.tags, + "SSH: hosts.status.offline", + ]) { + expect(tooltip.textContent).toContain(text); + } + }); + + it("keeps the comfortable name tooltip limited to status", async () => { + renderHostItem("comfortable"); + await userEvent.hover(screen.getByText(baseHost.name)); + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip.textContent).toBe("SSH: hosts.status.offline"); + expect(tooltip.textContent).not.toContain(baseHost.ip); + }); +}); + describe("HostItem click behavior", () => { function setup( hostClickBehavior: diff --git a/tests/frontend/sidebar/tree/sidebar-density.test.tsx b/tests/frontend/sidebar/tree/sidebar-density.test.tsx index 20eb780..0030283 100644 --- a/tests/frontend/sidebar/tree/sidebar-density.test.tsx +++ b/tests/frontend/sidebar/tree/sidebar-density.test.tsx @@ -123,6 +123,39 @@ afterEach(() => { localStorage.clear(); }); +it("starts compact host rows 48px apart before resize measurement", () => { + let initialSecondRowTransform: string | undefined; + vi.mocked(HTMLElement.prototype.getBoundingClientRect).mockImplementation( + function (this: HTMLElement) { + const bounds = rect(this); + if (!this.hasAttribute("data-index")) return bounds; + // Capture the estimate before synchronous DOM measurement can correct it. + initialSecondRowTransform ??= + this.parentElement?.querySelector('[data-index="1"]') + ?.style.transform; + return { ...bounds, height: 48, bottom: 48 }; + }, + ); + const hosts = [ + { id: "1", name: "Alpha", ip: "192.0.2.1", username: "u" }, + { id: "2", name: "Beta", ip: "192.0.2.2", username: "u" }, + ] as Host[]; + const view = render( + , + ); + const rows = view.container.querySelectorAll("[data-index]"); + expect(rows).toHaveLength(2); + expect(initialSecondRowTransform).toBe("translateY(48px)"); + expect(rows[1].style.transform).toBe("translateY(48px)"); +}); + it("keeps an unchanged folder's measured height after changing density", () => { const host = { id: "1",