/******************************************************************************** * Copyright (C) 2024-2025 EclipseSource and others. * * This program and the accompanying materials are made available under the * terms of the MIT License as outlined in the LICENSE File ********************************************************************************/ import '../../../style/tree/tree-common.css'; import '../../../style/tree/tree.css'; import { ConfigProvider, Spin, Table } from 'antd'; import { ColumnType, ExpandableConfig } from 'antd/es/table/interface'; import classNames from 'classnames'; import { Resizable } from 're-resizable'; import { default as React, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { debounce } from 'throttle-debounce'; import { HOST_EXTENSION } from 'vscode-messenger-common'; import { findNestedValue } from '../../base'; import { messenger } from '../../browser-types'; import { CommandDefinition } from '../../vscode/webview-types'; import { CDTTreeItem, CDTTreeItemResource, CDTTreeTableActionColumn, CDTTreeTableActionColumnCommand, CDTTreeTableColumnDefinition, CDTTreeTableStringColumn, CDTTreeWebviewContext } from '../common/index'; import { CDTTreeMessengerType } from '../common/tree-messenger-types'; import ActionCell from './components/cells/ActionCell'; import StringCell from './components/cells/StringCell'; import { ExpandIcon } from './components/expand-icon'; import { SearchOverlay } from './components/search-overlay'; import { TreeNavigator } from './components/treetable-navigator'; import { filterTree, getAncestors, traverseTree, useClickHook } from './components/utils'; const COLUMN_MIN_WIDTH = 50; const ACTION_COLUMN_WIDTH = 16 * 5; export interface CDTTreeApi { selectRow: (record: CDTTreeItem) => void; setEditRowKey: (key: string | undefined) => void; } /** * Component to render a tree table. */ export type CDTTreeProps = { /** * Information about the columns to be rendered. * If a single column is provided, then it will be rendered as a tree. */ columnDefinitions?: CDTTreeTableColumnDefinition[]; /** * Data source to be rendered. */ dataSource?: CDTTreeItem[]; /** * Whether the tree data is still loading. * Defaults to true while the data source or column definitions are undefined. */ loading?: boolean; /** * Function to sort the data source. */ dataSourceSorter?: (dataSource: CDTTreeItem[]) => CDTTreeItem[]; /** * Configuration for search behavior. */ search?: { /** * Search mode. * - 'client': filter locally in the webview (default) * - 'backend': keep current content until backend returns updated data */ mode?: 'client' | 'backend'; }; /** * Configuration for the expansion of the tree table. */ expansion?: { /** * List of expanded row keys. */ expandedRowKeys?: string[]; /** * Callback to be called when a row is expanded or collapsed. */ onExpand?: ExpandableConfig>['onExpand']; }; /** * Configuration for the pinning of the tree table. */ pin?: { /** * List of pinned row keys. */ pinnedRowKeys?: string[]; /** * Callback to be called when a row is pinned or unpinned. */ onPin?: (event: React.UIEvent, pinned: boolean, record: CDTTreeItem, api: CDTTreeApi) => void; }; /** * Configuration for the actions of the tree table. */ action?: { /** * Callback to be called when an action is triggered. */ onAction?: (event: React.UIEvent, command: CommandDefinition, value: unknown, record: CDTTreeItem, api: CDTTreeApi) => void; }; edit?: { /** * Callback to be called when a row is edited. */ onEdit?: (record: CDTTreeItem, value: string, api: CDTTreeApi) => void; }; }; interface ResizeableCell { resizeable?: boolean; maxWidth?: number; onDidColumnResize?: (event: MouseEvent | TouchEvent, width: number) => void; } interface BodyRowProps extends React.HTMLAttributes { 'data-row-key': string; record: CDTTreeItem; } const BodyRow = React.forwardRef((props, ref) => { // Support VSCode context menu items return (
); }); interface BodyCellProps extends React.HTMLAttributes, ResizeableCell {} const BodyCell = React.forwardRef((props, ref) => { const { resizeable, onDidColumnResize, maxWidth, className, onResize, style, ...rest } = props; const [width, setWidth] = useState(props.style?.width); useEffect(() => { if (resizeable) { setWidth(props.style?.width); } }, [props.style?.width]); const cell =
; if (!resizeable) { return cell; } return ( { const row = ref.closest('.ant-table-row'); row?.classList.add('ant-table-row-resizing'); }} onResize={(event, _direction, ref, _delta) => { onDidColumnResize?.(event, ref.clientWidth); }} onResizeStop={(event, _direction, ref, _delta) => { onDidColumnResize?.(event, ref.clientWidth); const row = ref.closest('.ant-table-row'); row?.classList.remove('ant-table-row-resizing'); }} handleClasses={{ right: 'resizable-handle' }} enable={{ bottom: false, bottomLeft: false, bottomRight: false, left: false, right: true, top: false, topLeft: false, topRight: false }} {...rest} > ); }); function useWindowSize() { const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight }); useLayoutEffect(() => { const updateSize = debounce(100, () => { setSize({ width: window.innerWidth, height: window.innerHeight }); }); window.addEventListener('resize', updateSize); return () => { window.removeEventListener('resize', updateSize); updateSize.cancel(); }; }, []); return size; } namespace InternalCommands { export const PIN_ID = 'cdt.tree.pin'; export const UNPIN_ID = 'cdt.tree.unpin'; } export const CDTTree = (props: CDTTreeProps) => { const { width, height } = useWindowSize(); const [globalSearchText, setGlobalSearchText] = useState(); const globalSearchRef = React.useRef(null); const autoSelectRowRef = React.useRef(false); const ref = React.useRef(null); const tblRef: Parameters[0]['ref'] = React.useRef(null); const isBackendSearch = props.search?.mode === 'backend'; const loading = props.loading ?? (props.dataSource === undefined || props.columnDefinitions === undefined); // ==== Data ==== const filteredData = useMemo(() => { let data = props.dataSource ?? []; if (globalSearchText && !isBackendSearch) { data = filterTree(data, globalSearchText); } if (props.dataSourceSorter) { data = props.dataSourceSorter([...data]); } return data; }, [props.dataSource, props.dataSourceSorter, globalSearchText, isBackendSearch]); // ==== Search ==== const onKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.ctrlKey && e.key === 'f') { e.preventDefault(); e.stopPropagation(); globalSearchRef.current?.show(); } }, []); const notifySearchChanged = useCallback((text: string) => { messenger.sendNotification(CDTTreeMessengerType.searchChanged, HOST_EXTENSION, { data: { text } }); }, []); const onSearchShow = useCallback(() => { const text = globalSearchRef.current?.value() ?? ''; setGlobalSearchText(text); notifySearchChanged(text); }, [notifySearchChanged]); const onSearchHide = useCallback(() => { setGlobalSearchText(undefined); autoSelectRowRef.current = true; notifySearchChanged(''); }, [notifySearchChanged]); const onSearchChange = useMemo( () => debounce(600, (text: string) => { setGlobalSearchText(text); notifySearchChanged(text); }), [notifySearchChanged] ); // ==== Selection ==== const [selection, setSelection] = useState(); const selectRow = useCallback( (record: CDTTreeItem) => { // Single select only if (selection?.key !== record.key) { setSelection(record); } }, [selection] ); // ==== Expansion ==== const expandedRowKeys = useMemo(() => { const expanded = new Set(props.expansion?.expandedRowKeys ?? []); if (globalSearchText && !isBackendSearch) { // client-side search: // on search expand all nodes that match the search const matchingExpansion = traverseTree(filteredData, { predicate: item => item.matching ?? false, mapper: getAncestors }); matchingExpansion.forEach(ancestorHierarchy => ancestorHierarchy.forEach(ancestor => expanded.add(ancestor.key))); } else { // normal mode or backend-search mode: // otherwise use the expandedRowKeys from the props but ensure that the selected element is also expanded if (autoSelectRowRef.current && selection) { getAncestors(selection).forEach(ancestor => expanded.add(ancestor.key)); } } return Array.from(expanded); }, [filteredData, globalSearchText, isBackendSearch, props.expansion?.expandedRowKeys, selection, autoSelectRowRef.current]); const handleExpand = useCallback( (expanded: boolean, record: CDTTreeItem) => { props.expansion?.onExpand?.(expanded, record); }, [props.expansion?.onExpand] ); // ==== Edit ==== const [editRowKey, setEditRowKey] = useState(); // ==== Index ==== const dataSourceIndex = useMemo(() => { const rowIndex = new Map(); const keyIndex = new Map(); let currentIndex = 0; const traverse = (nodes: CDTTreeItem[]) => { nodes.forEach(node => { rowIndex.set(node.id, currentIndex++); keyIndex.set(node.key, node); if (node.children && node.children.length > 0 && expandedRowKeys.includes(node.id)) { traverse(node.children); } }); }; traverse(filteredData ?? []); return { rowIndex, keyIndex }; }, [filteredData, expandedRowKeys]); // ==== Navigation ==== const navigator = React.useMemo( () => new TreeNavigator({ ref, rowIndex: dataSourceIndex.rowIndex, expandedRowKeys, expand: handleExpand, select: selectRow }), [ref, dataSourceIndex.rowIndex, expandedRowKeys, handleExpand, selectRow] ); const onTableKeyDown = useCallback( (event: React.KeyboardEvent) => { const selectedKey = selection?.key; if (!selectedKey) { return; } const record = dataSourceIndex.keyIndex.get(selectedKey); if (!record) { return; } switch (event.key) { case 'ArrowDown': { navigator.next(record); break; } case 'ArrowUp': { navigator.previous(record); break; } case 'ArrowLeft': { navigator.collapse(record); break; } case 'ArrowRight': { navigator.expand(record); break; } case 'Enter': { navigator.toggle(record); break; } case ' ': { navigator.toggle(record); break; } case 'PageUp': { navigator.previousPage(); break; } case 'PageDown': { navigator.nextPage(); break; } } }, [selection, dataSourceIndex] ); // ==== Columns ==== const [columnWidths, setColumnWidths] = useState>({}); const [prevWindowWidth, setPrevWindowWidth] = useState(width); const availableWidth = useMemo( () => width - ACTION_COLUMN_WIDTH - COLUMN_MIN_WIDTH * (props.columnDefinitions?.filter(c => c.resizable).length ?? 0), [width, props.columnDefinitions] ); const handleResize = (field: string) => (_: MouseEvent | TouchEvent, width: number) => { setColumnWidths(prev => ({ ...prev, [field]: width })); }; useEffect(() => { const delta = width - prevWindowWidth; if (delta < 0) { // Shrink columns that are too wide setColumnWidths(prev => { const newWidths = { ...prev }; for (const key in newWidths) { const currentWidth = newWidths[key]; if (currentWidth > availableWidth) { newWidths[key] = Math.max(currentWidth + delta, COLUMN_MIN_WIDTH); } } return newWidths; }); } setPrevWindowWidth(width); }, [width]); const getActions = useCallback((record: CDTTreeItem, column: CDTTreeTableActionColumn) => { const actions: CDTTreeTableActionColumnCommand[] = []; if (record.pinned !== undefined) { actions.push({ commandId: record.pinned ? InternalCommands.UNPIN_ID : InternalCommands.PIN_ID, title: record.pinned ? 'Unpin row' : 'Pin row', icon: record.pinned ? 'pin' : 'pinned', value: !record.pinned }); } actions.push(...column.commands); return actions; }, []); const onAction = useCallback( (event: React.UIEvent, command: CommandDefinition, value: unknown, record: CDTTreeItem) => { if (command.commandId === InternalCommands.PIN_ID || command.commandId === InternalCommands.UNPIN_ID) { event.stopPropagation(); props.pin?.onPin?.(event, value as boolean, record, treeApi); return; } return props.action?.onAction?.(event, command, value, record, treeApi); }, [props.action, props.pin?.onPin] ); const renderActionCell = useCallback( (column: CDTTreeTableActionColumn | undefined, record: CDTTreeItem) => { if (!column) { return undefined; } return ; }, [props.pin, props.action] ); const onSubmitEdit = useCallback( (record: CDTTreeItem, value: string) => { setEditRowKey(undefined); props.edit?.onEdit?.(record, value, treeApi); }, [props.edit?.onEdit] ); const onSubmitCancel = useCallback(() => { setEditRowKey(undefined); }, []); const onEdit = useCallback((record: CDTTreeItem, edit: boolean) => { if (edit) { selectRow(record); setEditRowKey(record.key); } else { setEditRowKey(undefined); } }, []); const isEditing = useCallback( (column: CDTTreeTableStringColumn, record: CDTTreeItem) => editRowKey === record.key || column.edit?.type === 'boolean' || column.edit?.type === 'enum', [editRowKey] ); const renderStringCell = useCallback( (column: CDTTreeTableStringColumn | undefined, record: CDTTreeItem) => { if (!column) { return undefined; } return ( ); }, [editRowKey] ); const columns = useMemo(() => { return ( props.columnDefinitions?.map>>(colDef => { const resizeable: ResizeableCell = { resizeable: colDef.resizable, maxWidth: availableWidth, onDidColumnResize: handleResize(colDef.field) }; if (colDef.type === 'string') { return { title: colDef.field, dataIndex: ['columns', colDef.field], width: columnWidths[colDef.field] ?? 0, ellipsis: true, render: renderStringCell, className: colDef.field, onCell: record => { const column = findNestedValue(record, ['columns', colDef.field]); return !column || !column.colSpan ? ({ ...resizeable } as React.HTMLAttributes & React.TdHTMLAttributes) : { ...resizeable, resizeable: column.colSpan !== 'fill', colSpan: column.colSpan === 'fill' ? props.columnDefinitions?.length : column.colSpan, style: { zIndex: 1 } }; } }; } if (colDef.type === 'action') { return { title: colDef.field, dataIndex: ['columns', colDef.field], width: ACTION_COLUMN_WIDTH, render: renderActionCell }; } return { ...resizeable, title: colDef.field, dataIndex: ['columns', colDef.field, 'label'], width: 200 }; }) ?? [] ); }, [props.columnDefinitions, columnWidths, renderStringCell, renderActionCell]); // ==== Handlers ==== // Ensure that even if we lose the active element through scrolling or other means, we can still navigate by restoring the focus useEffect(() => { if (!ref.current) { return; } const observer = new MutationObserver(() => { if (document.activeElement === globalSearchRef.current?.input()) { // do not steal focus from the search input return; } const selectedRow = document.querySelector('.ant-table-row-selected'); if (!selectedRow) { // Selected row was removed from the DOM, focus on the table ref.current?.focus(); } else if (selectedRow !== document.activeElement && !selectedRow.contains(document.activeElement)) { // Selected row is still in the DOM, but not focused selectedRow?.focus(); } }); observer.observe(ref.current, { childList: true, subtree: true }); return () => observer.disconnect(); }, [ref.current]); // Abort scrolling when mouse drag was finished (e.g., left mouse button is no longer pressed) outside the iframe useEffect(() => { const abortScroll = (event: MouseEvent) => { if (!(event.buttons & 1)) { // left button is no longer pressed... const elements = document.getElementsByClassName('ant-table-tbody-virtual-scrollbar-thumb-moving'); if (elements.length > 0) { // ...but we are still scrolling the thumb (left button was released outside iframe) -> abort scrolling window.dispatchEvent(new MouseEvent('mouseup')); } } }; document.addEventListener('mouseenter', abortScroll); return () => document.removeEventListener('mouseenter', abortScroll); }, []); // Scroll to selected row if autoSelectRowRef is set useEffect(() => { if (autoSelectRowRef.current && selection) { tblRef.current?.scrollTo({ key: selection.key }); autoSelectRowRef.current = false; } }, [autoSelectRowRef.current]); const onRowSingleClick = useCallback( (_event: React.MouseEvent, record: CDTTreeItem) => { const isExpanded = expandedRowKeys?.includes(record.id); handleExpand(!isExpanded, record); selectRow(record); }, [props.expansion?.expandedRowKeys] ); const onRowClick = useClickHook({ onSingleClick: onRowSingleClick, delay: 10 // We don't have a double click event for now }); // ==== Return ==== const treeApi = useMemo( () => ({ selectRow, setEditRowKey }), [selectRow, setEditRowKey] ); return (
loading ? (
Loading…
) : (
No data available.
) } >
> ref={tblRef} columns={columns} dataSource={filteredData} components={{ body: { row: BodyRow, cell: BodyCell } }} virtual scroll={{ x: width, y: height - 2 }} showHeader={false} pagination={false} rowClassName={record => classNames({ 'ant-table-row-selected': record.key === selection?.key, 'ant-table-row-matched': record.matching ?? false }) } onRow={record => ({ record, onClick: event => onRowClick(event, record) })} expandable={{ expandIcon: props => , showExpandColumn: true, expandedRowKeys: expandedRowKeys, onExpand: handleExpand }} />
); };