From 09b50091871f84af9f60cbd3ae967e0aeff9efa0 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 21 Jul 2026 13:58:38 +0200 Subject: [PATCH] feat(webui): extend shared explorer tree actions --- webui/package.json | 1 + webui/src/components/ExplorerTree.tsx | 273 +++++++++++++++----------- webui/src/styles/components.css | 47 +++++ webui/tests/explorer-tree.test.tsx | 55 ++++++ webui/tsconfig.component-tests.json | 2 + 5 files changed, 261 insertions(+), 117 deletions(-) create mode 100644 webui/tests/explorer-tree.test.tsx diff --git a/webui/package.json b/webui/package.json index f018cd2..18293c8 100644 --- a/webui/package.json +++ b/webui/package.json @@ -24,6 +24,7 @@ "test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs", "test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js", "test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs", + "test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js", "test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js", "test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js", "test:module-permutations": "node scripts/test-module-permutations.mjs", diff --git a/webui/src/components/ExplorerTree.tsx b/webui/src/components/ExplorerTree.tsx index be2290b..b7aea33 100644 --- a/webui/src/components/ExplorerTree.tsx +++ b/webui/src/components/ExplorerTree.tsx @@ -10,15 +10,13 @@ export type ExplorerTreeNodeContext = { disabled: boolean; }; -export type ExplorerTreeProps = { +type ExplorerTreeCommonProps = { nodes: T[]; getNodeId: (node: T) => string; getNodeLabel: (node: T) => string; getNodeChildren: (node: T) => T[]; activeId?: string; - expandedIds: Set; onOpen: (node: T, context: ExplorerTreeNodeContext) => void; - onToggle: (node: T, context: ExplorerTreeNodeContext) => void; disabled?: boolean; depth?: number; className?: string; @@ -29,6 +27,7 @@ export type ExplorerTreeProps = { nodeButtonBaseClassName?: string; renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode; renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode; + renderNodeActions?: (node: T, context: ExplorerTreeNodeContext) => ReactNode; getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined; getNodeWrapStyle?: (node: T, context: ExplorerTreeNodeContext) => CSSProperties | undefined; getNodeButtonClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined; @@ -41,122 +40,162 @@ export type ExplorerTreeProps = { onDrop?: (event: ReactDragEvent, node: T, context: ExplorerTreeNodeContext) => void; }; -export default function ExplorerTree({ - nodes, - getNodeId, - getNodeLabel, - getNodeChildren, - activeId = "", - expandedIds, - onOpen, - onToggle, - disabled = false, - depth = 1, - className = "", - childrenBaseClassName = "explorer-tree-children file-tree-children", - nodeContainerClassName = "", - nodeWrapBaseClassName = "explorer-tree-node-wrap file-tree-node-wrap", - toggleBaseClassName = "explorer-tree-toggle file-tree-toggle", - nodeButtonBaseClassName = "explorer-tree-node file-tree-node", - renderToggleIcon, - renderNodeContent, - getNodeWrapClassName, - getNodeWrapStyle, - getNodeButtonClassName, - getNodeDraggable, - onContextMenu, - onDragStart, - onDragEnd, - onDragOver, - onDragLeave, - onDrop -}: ExplorerTreeProps) { - if (nodes.length === 0) return null; +type CollapsibleExplorerTreeProps = { + collapsible?: true; + expandedIds: ReadonlySet; + onToggle: (node: T, context: ExplorerTreeNodeContext) => void; +}; - return ( -
- {nodes.map((node) => { - const nodeId = getNodeId(node); - const children = getNodeChildren(node); - const hasChildren = children.length > 0; - const expanded = expandedIds.has(nodeId); - const active = activeId === nodeId; - const context: ExplorerTreeNodeContext = { depth, active, expanded, hasChildren, disabled }; - const draggable = getNodeDraggable?.(node, context) ?? false; +type StaticExplorerTreeProps = { + collapsible: false; + expandedIds?: never; + onToggle?: never; +}; - return ( -
-
onContextMenu(event, node, context) : undefined} - onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined} - onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined} - onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined} - onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined} - onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}> - - - +export type ExplorerTreeProps = ExplorerTreeCommonProps & ( + CollapsibleExplorerTreeProps | StaticExplorerTreeProps +); + +type SafeNode = { + id: string; + node: T; +}; + +const EMPTY_EXPANDED_IDS: ReadonlySet = new Set(); + +export default function ExplorerTree(props: ExplorerTreeProps) { + const { + nodes, + getNodeId, + getNodeLabel, + getNodeChildren, + activeId = "", + onOpen, + disabled = false, + depth = 1, + className = "", + childrenBaseClassName = "explorer-tree-children file-tree-children", + nodeContainerClassName = "", + nodeWrapBaseClassName = "explorer-tree-node-wrap file-tree-node-wrap", + toggleBaseClassName = "explorer-tree-toggle file-tree-toggle", + nodeButtonBaseClassName = "explorer-tree-node file-tree-node", + renderToggleIcon, + renderNodeContent, + renderNodeActions, + getNodeWrapClassName, + getNodeWrapStyle, + getNodeButtonClassName, + getNodeDraggable, + onContextMenu, + onDragStart, + onDragEnd, + onDragOver, + onDragLeave, + onDrop + } = props; + const collapsible = props.collapsible !== false; + const expandedIds = props.collapsible === false ? EMPTY_EXPANDED_IDS : props.expandedIds; + const onToggle = props.collapsible === false ? undefined : props.onToggle; + + function pathSafeNodes(levelNodes: T[], ancestorIds: ReadonlySet): SafeNode[] { + const siblingIds = new Set(); + const safeNodes: SafeNode[] = []; + for (const node of levelNodes) { + const id = getNodeId(node); + if (ancestorIds.has(id) || siblingIds.has(id)) continue; + siblingIds.add(id); + safeNodes.push({ id, node }); + } + return safeNodes; + } + + function renderLevel(levelNodes: T[], currentDepth: number, ancestorIds: ReadonlySet, root = false): ReactNode { + const safeNodes = pathSafeNodes(levelNodes, ancestorIds); + if (safeNodes.length === 0) return null; + + return ( +
+ {safeNodes.map(({ id: nodeId, node }) => { + const nextAncestorIds = new Set(ancestorIds); + nextAncestorIds.add(nodeId); + const children = getNodeChildren(node); + const hasChildren = pathSafeNodes(children, nextAncestorIds).length > 0; + const expanded = collapsible ? expandedIds.has(nodeId) : hasChildren; + const active = activeId === nodeId; + const context: ExplorerTreeNodeContext = { depth: currentDepth, active, expanded, hasChildren, disabled }; + const draggable = getNodeDraggable?.(node, context) ?? false; + const nodeActions = renderNodeActions?.(node, context); + const hasNodeActions = nodeActions !== null && nodeActions !== undefined && nodeActions !== false; + + return ( +
+
onContextMenu(event, node, context) : undefined} + onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined} + onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined} + onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined} + onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined} + onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined} + > + {collapsible ? ( + + ) : ( + + )} + + {hasNodeActions && ( +
+ {nodeActions} +
+ )} +
+ {hasChildren && expanded && renderLevel(children, currentDepth + 1, nextAncestorIds)}
- {hasChildren && expanded && - - - } -
); - - })} -
); + ); + })} +
+ ); + } + return renderLevel(nodes, depth, new Set(), true); } diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index 4f70fd3..7e4541a 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -2256,6 +2256,10 @@ border-radius: 9px; } +.explorer-tree-node-wrap.has-actions { + grid-template-columns: 26px minmax(0, 1fr) auto; +} + .explorer-tree-node, .explorer-tree-toggle { border: 0; @@ -2278,6 +2282,11 @@ opacity: .55; } +.explorer-tree-toggle.explorer-tree-toggle-static { + cursor: default; + pointer-events: none; +} + .explorer-tree-node { display: flex; align-items: center; @@ -2297,6 +2306,44 @@ white-space: nowrap; } +.explorer-tree-node-content { + display: grid; + gap: 2px; + min-width: 0; +} + +.explorer-tree-node-content strong, +.explorer-tree-node-content small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.explorer-tree-node-content small { + color: var(--muted); + font-size: 12px; +} + +.explorer-tree-actions { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 4px; + padding-right: 4px; +} + +.explorer-tree-toolbar { + display: flex; + justify-content: flex-start; + margin-bottom: 12px; +} + +.explorer-tree-scroll-region { + max-height: 640px; + overflow: auto; +} + .explorer-tree-node-wrap:hover, .explorer-tree-node-wrap:focus-within, .explorer-tree-node-wrap.is-active { diff --git a/webui/tests/explorer-tree.test.tsx b/webui/tests/explorer-tree.test.tsx new file mode 100644 index 0000000..c58cb82 --- /dev/null +++ b/webui/tests/explorer-tree.test.tsx @@ -0,0 +1,55 @@ +function assert(condition: unknown, message = "assertion failed"): void { + if (!condition) throw new Error(message); +} + +function count(markup: string, pattern: RegExp): number { + return markup.match(pattern)?.length ?? 0; +} + +import { renderToStaticMarkup } from "react-dom/server"; +import ExplorerTree from "../src/components/ExplorerTree"; +import IconButton from "../src/components/IconButton"; + +type TestNode = { + id: string; + label: string; + children: TestNode[]; +}; + +function noop() {} + +const alpha: TestNode = { id: "alpha", label: "Alpha", children: [] }; +const beta: TestNode = { id: "beta", label: "Beta", children: [] }; +alpha.children.push(beta); +beta.children.push(alpha); + +const markup = renderToStaticMarkup( + node.id} + getNodeLabel={(node) => node.label} + getNodeChildren={(node) => node.children} + activeId="beta" + collapsible={false} + onOpen={noop} + renderNodeContent={(node) => {`node-${node.id}`}} + renderNodeActions={(node) => ( + +} onClick={noop} /> + )} + /> +); + +assert(markup.includes("node-alpha"), "the root node is rendered"); +assert(markup.includes("node-beta"), "non-collapsible trees render descendants without expansion state"); +assert(count(markup, /node-alpha/g) === 1, "a cycle does not render an ancestor again"); +assert(count(markup, /node-beta/g) === 1, "duplicate paths do not duplicate descendants"); +assert(count(markup, /explorer-tree-toggle-static/g) === 2, "non-collapsible nodes render static tree affordances"); +assert(!markup.includes('