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
237 changes: 133 additions & 104 deletions src/frontend/sidebar/tree/HostItem/HostItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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}`
Expand All @@ -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 <Badge key={badge.id} host={host} />;
})}
{rowFields.showSharedBadge && host.isShared && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-0.5 text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
<Users className="size-2.5" />
{t("hosts.sharing.sharedBadge")}
</span>
</TooltipTrigger>
<TooltipContent side="right">
{t("hosts.sharing.sharedBadgeTooltip", {
owner: host.ownerUsername || "?",
level: t(
`hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`,
),
})}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</>
);

const trayToggle = !selectionMode && (shouldUseClickTray || actionsOnly) && (
<button
type="button"
aria-expanded={isTrayOpen}
title={isTrayOpen ? t("hosts.collapseActions") : t("hosts.expandActions")}
onClick={(e) => {
e.stopPropagation();
onTrayOpenChange?.(!isTrayOpen);
}}
className="ml-auto flex items-center justify-center size-[17.5px] rounded text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted-foreground/10 transition-colors shrink-0"
>
<ChevronRight
className={`size-[10.5px] transition-transform duration-150 ${isTrayOpen ? "rotate-90" : ""}`}
/>
</button>
);

return (
<div
draggable={canDrag}
Expand Down Expand Up @@ -1140,8 +1179,25 @@ export function HostItem({
{host.name}
</span>
</TooltipTrigger>
<TooltipContent side="right">
{buildStatusTooltip(host, availability, t)}
<TooltipContent
side="right"
className={
isCompact ? "max-w-72 whitespace-pre-line break-words" : ""
}
>
{isCompact
? [
host.name,
formatHostAddress(host, {
showUsername: true,
showPort: true,
}),
host.tags?.join(", "),
buildStatusTooltip(host, availability, t),
]
.filter(Boolean)
.join("\n")
: buildStatusTooltip(host, availability, t)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
Expand All @@ -1164,91 +1220,68 @@ export function HostItem({
</Tooltip>
</TooltipProvider>
)}
{rowFields.showBadges &&
badges.map((badge) => {
const Badge = badge.component;
return <Badge key={badge.id} host={host} />;
})}
{rowFields.showSharedBadge && host.isShared && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-0.5 text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
<Users className="size-2.5" />
{t("hosts.sharing.sharedBadge")}
</span>
</TooltipTrigger>
<TooltipContent side="right">
{t("hosts.sharing.sharedBadgeTooltip", {
owner: host.ownerUsername || "?",
level: t(
`hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`,
),
})}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isCompact &&
showTags &&
host.tags?.slice(0, 2).map((tag) => (
<span
key={tag}
data-testid="host-inline-tag"
className="text-[9px] px-1 py-px bg-muted/60 text-muted-foreground/70 lowercase leading-none truncate max-w-[5rem] min-w-0"
>
{tag}
</span>
))}
{isCompact && showTags && (host.tags?.length ?? 0) > 2 && (
<span className="text-[9px] text-muted-foreground/40 shrink-0 leading-none">
+{host.tags!.length - 2}
</span>
)}
{isCompact && rowFields.showAddress && (
<span
className="text-[11px] text-muted-foreground/70 truncate leading-none ml-auto max-w-[50%] shrink-0"
title={host.ip}
>
{formatHostAddress(host, { ...rowFields, showUsername: false })}
{!isCompact && hostBadges}
{!isCompact && trayToggle}
</div>

{isCompact && (
<div
data-testid="host-details-row"
className="flex items-center min-w-0 min-h-[19px]"
>
<span className="sr-only">
{formatHostAddress(host, { showUsername: true, showPort: true })}
{host.tags?.length ? `, ${host.tags.join(", ")}` : ""}
</span>
)}
{isCompact && !selectionMode && (
<div
data-testid="host-inline-actions"
className={`flex items-center gap-[2px] min-w-0 max-w-[60%] overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&>*]:shrink-0 ${alwaysShowTray || actionsOnly || trayOpenState || hoverTrayOpen ? "" : "hidden"}`}
>
{connectionButtons}
<div
className={
alwaysShowTray || trayOpenState || hoverTrayOpen
? "flex items-center gap-[2px]"
: "hidden"
}
>
{managementButtons}
</div>
<div className="flex items-center gap-1 flex-1 min-w-0 overflow-hidden">
{hostBadges}
{rowFields.showAddress && (
<span
className="text-[11px] text-muted-foreground/70 font-mono min-w-0 truncate leading-none"
title={host.ip}
>
{formatHostAddress(host, {
...rowFields,
showUsername: false,
})}
</span>
)}
{showTags && !!host.tags?.length && (
<div className="flex flex-1 basis-0 min-w-0 flex-wrap h-[11px] overflow-hidden gap-x-1">
{host.tags.map((tag) => (
<span
key={tag}
data-testid="host-inline-tag"
className="text-[9px] px-1 py-px bg-muted/60 text-muted-foreground/70 lowercase leading-none truncate max-w-[5rem] min-w-0 shrink-0"
>
{tag}
</span>
))}
</div>
)}
</div>
)}
{!selectionMode && (shouldUseClickTray || actionsOnly) && (
<button
title={
isTrayOpen
? t("hosts.collapseActions")
: t("hosts.expandActions")
}
onClick={(e) => {
e.stopPropagation();
onTrayOpenChange?.(!isTrayOpen);
}}
className="ml-auto flex items-center justify-center size-[17.5px] rounded text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted-foreground/10 transition-colors shrink-0"
>
<ChevronRight
className={`size-[10.5px] transition-transform duration-150 ${isTrayOpen ? "rotate-90" : ""}`}
/>
</button>
)}
</div>
{!selectionMode && (
<div className="flex items-center min-w-0 max-w-full shrink-0 ml-auto">
<div
data-testid="host-inline-actions"
className={`flex items-center gap-[2px] min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&>*]:shrink-0 ${alwaysShowTray || actionsOnly || trayOpenState || hoverTrayOpen ? "" : "hidden"}`}
>
{connectionButtons}
<div
className={
alwaysShowTray || trayOpenState || hoverTrayOpen
? "flex items-center gap-[2px]"
: "hidden"
}
>
{managementButtons}
</div>
</div>
{trayToggle}
</div>
)}
</div>
)}

{tokens.showAddressRow && (
<span className="text-[11px] text-muted-foreground/60 truncate leading-none font-mono">
Expand All @@ -1258,20 +1291,18 @@ export function HostItem({

{/* Tag pills */}
{showTags && !isCompact && host.tags && host.tags.length > 0 && (
<div
className={`flex items-center gap-1 min-w-0 overflow-hidden ${tokens.showTagsRow ? "" : "-mt-0.5"}`}
>
{host.tags.slice(0, isCompact ? 2 : 4).map((tag) => (
<div className="flex items-center gap-1 min-w-0 overflow-hidden">
{host.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="text-[9px] px-1.5 py-[1px] bg-muted/60 text-muted-foreground/70 lowercase shrink-0 leading-[1.4]"
>
{tag}
</span>
))}
{host.tags.length > (isCompact ? 2 : 4) && (
{host.tags.length > 4 && (
<span className="text-[9px] text-muted-foreground/40 shrink-0">
+{host.tags.length - (isCompact ? 2 : 4)}
+{host.tags.length - 4}
</span>
)}
</div>
Expand All @@ -1283,9 +1314,7 @@ export function HostItem({
(alwaysShowTray ||
actionsOnly ||
(shouldUseClickTray && isTrayOpen)) && (
<div
className={`flex items-center flex-wrap ${isCompact ? "gap-[2px] pt-[3px]" : "gap-[3.5px]"}`}
>
<div className="flex items-center flex-wrap gap-[3.5px]">
{connectionButtons}
</div>
)}
Expand Down
51 changes: 15 additions & 36 deletions src/frontend/sidebar/tree/SidebarTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading