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",