diff --git a/src/ui/sidebar/tree/SidebarTree.tsx b/src/ui/sidebar/tree/SidebarTree.tsx index d736a5b54..25b690c0b 100644 --- a/src/ui/sidebar/tree/SidebarTree.tsx +++ b/src/ui/sidebar/tree/SidebarTree.tsx @@ -820,9 +820,13 @@ export function SidebarTree({ getItemKey: (index) => { const row = visibleRows[index]; if (!row) return index; + // Group-by views (e.g. by tags) can list the same host under several + // group folders, so parentPath is folded in to keep each occurrence's + // key unique -- a shared key across rows made the virtualizer collide + // their measured positions and render them stacked on top of each other. return isFolder(row.item) ? `folder:${row.item.path ?? row.item.name}` - : `host:${row.item.id}`; + : `${row.parentPath}>host:${row.item.id}`; }, }); diff --git a/src/ui/sidebar/tree/visible-rows.ts b/src/ui/sidebar/tree/visible-rows.ts index 8baad6f10..fe123c926 100644 --- a/src/ui/sidebar/tree/visible-rows.ts +++ b/src/ui/sidebar/tree/visible-rows.ts @@ -34,7 +34,18 @@ export function folderHasMatch(folder: HostFolder, query: string): boolean { return false; } -export type VirtualRow = { item: Host | HostFolder; depth: number }; +/** Sentinel parent for rows sitting at the tree root (no folder). */ +export const ROOT_PARENT = "__root__"; + +// parentPath identifies which folder/group a row was collected under. Group-by +// views (e.g. group by tags) can list the same host under several synthetic +// group folders, so the host id alone isn't a unique row identity -- the +// virtualizer needs parentPath+id to tell those rows apart. +export type VirtualRow = { + item: Host | HostFolder; + depth: number; + parentPath: string; +}; function collectVisibleHostRows( host: Host, @@ -42,9 +53,10 @@ function collectVisibleHostRows( closedHostParents: Set, out: VirtualRow[], depth: number, + parentPath: string, ): void { if (!query || hostMatchesQuery(host, query) || hostHasMatch(host, query)) { - out.push({ item: host, depth }); + out.push({ item: host, depth, parentPath }); } else { return; } @@ -57,7 +69,14 @@ function collectVisibleHostRows( const isOpen = query ? true : !closedHostParents.has(hostExpandKey(host)); if (!isOpen) return; for (const child of childHosts) { - collectVisibleHostRows(child, query, closedHostParents, out, depth + 1); + collectVisibleHostRows( + child, + query, + closedHostParents, + out, + depth + 1, + `${parentPath}>host:${host.id}`, + ); } } @@ -68,13 +87,15 @@ export function collectVisibleRows( out: VirtualRow[] = [], depth = 0, closedHostParents: Set = new Set(), + parentPath: string = ROOT_PARENT, ): VirtualRow[] { for (const child of children) { if (isFolder(child)) { const visible = query ? folderHasMatch(child, query) : true; if (!visible) continue; - out.push({ item: child, depth }); - const childOpen = query ? true : openSet.has(child.path ?? child.name); + const path = child.path ?? child.name; + out.push({ item: child, depth, parentPath }); + const childOpen = query ? true : openSet.has(path); if (childOpen) collectVisibleRows( child.children, @@ -83,9 +104,17 @@ export function collectVisibleRows( out, depth + 1, closedHostParents, + path, ); } else { - collectVisibleHostRows(child, query, closedHostParents, out, depth); + collectVisibleHostRows( + child, + query, + closedHostParents, + out, + depth, + parentPath, + ); } } return out; @@ -123,9 +152,6 @@ export function collectAllFolderPaths( return Array.from(paths).sort((a, b) => a.localeCompare(b)); } -/** Sentinel parent for rows sitting at the tree root (no folder). */ -export const ROOT_PARENT = "__root__"; - export function rowKey(item: Host | HostFolder): string { return isFolder(item) ? `folder:${item.path ?? item.name}` diff --git a/src/ui/tests/sidebar/sidebar-tree-visible-rows.test.ts b/src/ui/tests/sidebar/sidebar-tree-visible-rows.test.ts index e32411fe8..900196097 100644 --- a/src/ui/tests/sidebar/sidebar-tree-visible-rows.test.ts +++ b/src/ui/tests/sidebar/sidebar-tree-visible-rows.test.ts @@ -133,6 +133,34 @@ describe("collectVisibleRows with sub-host nesting", () => { }); }); +describe("collectVisibleRows with group-by views", () => { + it("gives each occurrence of a multi-tag host a distinct parentPath", () => { + // Group-by-tags puts the same host under one synthetic folder per tag, + // so a host with two tags shows up twice in the tree with the same id. + const multiTagHost = host("9", "shared-box"); + const tree: (Host | HostFolder)[] = [ + { + name: "prod", + path: "__group__:tag:prod", + children: [multiTagHost], + }, + { + name: "web", + path: "__group__:tag:web", + children: [multiTagHost], + }, + ]; + const rows = collectVisibleRows( + tree, + "", + new Set(["__group__:tag:prod", "__group__:tag:web"]), + ); + const hostRows = rows.filter((r) => !isFolder(r.item)); + expect(hostRows).toHaveLength(2); + expect(hostRows[0].parentPath).not.toBe(hostRows[1].parentPath); + }); +}); + describe("buildReorderRows", () => { it("keys folders by path and hosts by id", () => { const folder: HostFolder = {