diff --git a/webview-ui/src/diagnostics_panel/App.css b/webview-ui/src/diagnostics_panel/App.css index eb555d2..bd85ade 100644 --- a/webview-ui/src/diagnostics_panel/App.css +++ b/webview-ui/src/diagnostics_panel/App.css @@ -267,6 +267,19 @@ main { text-align: right; } +.minecraft-entity-system-count-badge { + display: flex; + align-items: center; + height: 57px; /* Forces the Entity and Systems charts to be visually aligned */ +} + +.minecraft-entity-system-count-badge-content { + font-style: italic; + padding: 5px; + border-radius: 5px; + background: color-mix(in srgb, var(--vscode-list-activeSelectionBackground) 33%, var(--vscode-editor-background)); +} + .minecraft-grouped-statistic-table-root { width: 100%; } @@ -320,6 +333,12 @@ main { width: 90px; } +.minecraft-grouped-statistic-table-grid-select { + text-align: center !important; + width: 72px; + padding: 6px 4px !important; +} + .minecraft-grouped-statistic-table-grid-pin { text-align: center !important; width: 34px; @@ -363,6 +382,10 @@ main { background: color-mix(in srgb, var(--vscode-list-highlightForeground) 12%, var(--vscode-editor-background)); } +.minecraft-grouped-statistic-row-selected { + box-shadow: inset 3px 0 0 var(--vscode-focusBorder); +} + .minecraft-profiler-flame-stream-root { width: 100%; display: flex; diff --git a/webview-ui/src/diagnostics_panel/controls/MinecraftGroupedStatisticTable.tsx b/webview-ui/src/diagnostics_panel/controls/MinecraftGroupedStatisticTable.tsx index 6a8d3d1..b86eaf4 100644 --- a/webview-ui/src/diagnostics_panel/controls/MinecraftGroupedStatisticTable.tsx +++ b/webview-ui/src/diagnostics_panel/controls/MinecraftGroupedStatisticTable.tsx @@ -1,6 +1,6 @@ // Copyright (C) Microsoft Corporation. All rights reserved. -import { useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { MultipleStatisticProvider, StatisticUpdatedMessage } from '../StatisticProvider'; import { StatisticResolver } from '../StatisticResolver'; import { VSCodeButton, VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from '@vscode/webview-ui-toolkit/react'; @@ -29,7 +29,7 @@ export enum MinecraftGroupedStatisticTableColumnAggregation { Sum = 'sum', } -type GroupedStatisticTableRow = { +export type GroupedStatisticTableRow = { category: string; values: (string | number)[]; time: number; @@ -56,6 +56,17 @@ type GroupedStatisticTableRowAction = { type NonConsolidatedColumnResolver = (event: StatisticUpdatedMessage, valueLabels: string[]) => number | undefined; +export type MinecraftGroupedStatisticTableSelectionSnapshot = { + selectedGroupKeys: string[]; + selectedRowKeys: string[]; + resolvedSelectedRows: GroupedStatisticTableRow[]; +}; + +export type MinecraftGroupedStatisticTableHandle = { + getSelectionSnapshot: () => MinecraftGroupedStatisticTableSelectionSnapshot; + clearSelection: () => void; +}; + type MinecraftGroupedStatisticTableProps = { title: string; showTitle?: boolean; @@ -75,6 +86,10 @@ type MinecraftGroupedStatisticTableProps = { nonConsolidatedColumnResolver?: NonConsolidatedColumnResolver; valueFormatter?: (value: string | number, columnIndex: number) => string; prettifyNames?: boolean; + selectionEnabled?: boolean; + selectionHeaderLabel?: string; + defaultSelectAllGroups?: boolean; + onSelectionChange?: (snapshot: MinecraftGroupedStatisticTableSelectionSnapshot) => void; }; const sortOrderOptions = [ @@ -87,6 +102,8 @@ const sortTypeOptions = [ { id: MinecraftGroupedStatisticTableSortType.Numerical, label: 'Numerical' }, ]; +const NON_CONSOLIDATED_STALE_TICK_THRESHOLD = 3; + function getColumnIndexFromSortColumn(sortColumn: string, valueLabelsLength: number): number | undefined { const columnIndex = parseInt(sortColumn.replace('value_', '')); if (isNaN(columnIndex) || columnIndex < 0 || columnIndex >= valueLabelsLength) { @@ -223,6 +240,7 @@ function processChildrenStringValues( valueLabels: string[], prettifyNames: boolean, eventTime: number, + observedCategories?: Set, ): void { childrenStringValues.forEach(childRow => { // The first childRow value is the rowName, @@ -258,29 +276,40 @@ function processChildrenStringValues( values, time: eventTime, }); + observedCategories?.add(cleanRowName); }); } -export default function MinecraftGroupedStatisticTable({ - title, - showTitle = true, - statisticDataProvider, - statisticResolver, - keyLabel, - valueLabels, - getGroupKey, - displayMode = MinecraftGroupedStatisticTableDisplayMode.Grouped, - groupColumnAggregations = [], - groupCountLabel = '', - defaultCollapsed = true, - defaultSortOrder = MinecraftGroupedStatisticTableSortOrder.Descending, - defaultSortType = MinecraftGroupedStatisticTableSortType.Numerical, - defaultSortColumn, - rowAction, - nonConsolidatedColumnResolver, - valueFormatter, - prettifyNames = false, -}: MinecraftGroupedStatisticTableProps): JSX.Element { +const MinecraftGroupedStatisticTable = forwardRef< + MinecraftGroupedStatisticTableHandle, + MinecraftGroupedStatisticTableProps +>(function MinecraftGroupedStatisticTable( + { + title, + showTitle = true, + statisticDataProvider, + statisticResolver, + keyLabel, + valueLabels, + getGroupKey, + displayMode = MinecraftGroupedStatisticTableDisplayMode.Grouped, + groupColumnAggregations = [], + groupCountLabel = '', + defaultCollapsed = true, + defaultSortOrder = MinecraftGroupedStatisticTableSortOrder.Descending, + defaultSortType = MinecraftGroupedStatisticTableSortType.Numerical, + defaultSortColumn, + rowAction, + nonConsolidatedColumnResolver, + valueFormatter, + prettifyNames = false, + selectionEnabled = false, + selectionHeaderLabel = 'Selected', + defaultSelectAllGroups = false, + onSelectionChange, + }: MinecraftGroupedStatisticTableProps, + ref, +): JSX.Element { const sortColumnOptions = useMemo( () => [ { id: MinecraftGroupedStatisticTableSortColumn.Key, label: keyLabel }, @@ -299,9 +328,82 @@ export default function MinecraftGroupedStatisticTable({ const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [pinnedGroups, setPinnedGroups] = useState>(new Set()); const [pinnedRows, setPinnedRows] = useState>(new Set()); + const [selectedGroups, setSelectedGroups] = useState>(new Set()); + const [selectedRows, setSelectedRows] = useState>(new Set()); + const [deselectedRowsInSelectedGroups, setDeselectedRowsInSelectedGroups] = useState>(new Set()); + const [hasInitializedDefaultSelection, setHasInitializedDefaultSelection] = useState(false); + const nonConsolidatedTickCounterRef = useRef(0); + const nonConsolidatedLastEventTimeRef = useRef(undefined); + const nonConsolidatedLastSeenTickByCategoryRef = useRef>(new Map()); const isGroupedMode = displayMode === MinecraftGroupedStatisticTableDisplayMode.Grouped; const groupKeyResolver = getGroupKey ?? ((rowCategory: string) => rowCategory); + const groupedRowsByKey = useMemo((): Map => { + const groupedRows = new Map(); + + data.forEach(row => { + const groupKey = groupKeyResolver(row.category); + const existing = groupedRows.get(groupKey); + + if (existing) { + existing.push(row); + return; + } + + groupedRows.set(groupKey, [row]); + }); + + return groupedRows; + }, [data, groupKeyResolver]); + + const rowKeysByGroup = useMemo((): Map => { + const rowKeys = new Map(); + + groupedRowsByKey.forEach((rows, groupKey) => { + rowKeys.set( + groupKey, + rows.map(row => getRowPinKey(groupKey, row.category)), + ); + }); + + return rowKeys; + }, [groupedRowsByKey]); + + const rowLookup = useMemo((): Map => { + const lookup = new Map(); + + groupedRowsByKey.forEach((rows, groupKey) => { + rows.forEach(row => { + lookup.set(getRowPinKey(groupKey, row.category), row); + }); + }); + + return lookup; + }, [groupedRowsByKey]); + + const rowGroupLookup = useMemo((): Map => { + const lookup = new Map(); + + groupedRowsByKey.forEach((rows, groupKey) => { + rows.forEach(row => { + lookup.set(getRowPinKey(groupKey, row.category), groupKey); + }); + }); + + return lookup; + }, [groupedRowsByKey]); + + const isRowSelected = useCallback( + (groupKey: string, rowPinKey: string): boolean => { + if (selectedGroups.has(groupKey)) { + return !deselectedRowsInSelectedGroups.has(rowPinKey); + } + + return selectedRows.has(rowPinKey); + }, + [deselectedRowsInSelectedGroups, selectedGroups, selectedRows], + ); + const controlIdBase = useId().replace(/:/g, ''); const onSelectedSortOrderChange = useCallback((event: Event | React.FormEvent): void => { @@ -352,6 +454,172 @@ export default function MinecraftGroupedStatisticTable({ }); }, []); + const onToggleSelectedGroup = useCallback( + (groupKey: string): void => { + const groupRowKeys = rowKeysByGroup.get(groupKey) || []; + const selectedGroupRowCount = groupRowKeys.filter(rowKey => isRowSelected(groupKey, rowKey)).length; + const isGroupFullySelected = groupRowKeys.length > 0 && selectedGroupRowCount === groupRowKeys.length; + + setSelectedGroups(previousSelectedGroups => { + const updated = new Set(previousSelectedGroups); + + if (isGroupFullySelected) { + updated.delete(groupKey); + } else { + updated.add(groupKey); + } + + return updated; + }); + + setSelectedRows(previousSelectedRows => { + const updated = new Set(previousSelectedRows); + + groupRowKeys.forEach(rowKey => { + updated.delete(rowKey); + }); + + return updated; + }); + + setDeselectedRowsInSelectedGroups(previousDeselectedRows => { + const updated = new Set(previousDeselectedRows); + + groupRowKeys.forEach(rowKey => { + updated.delete(rowKey); + }); + + return updated; + }); + }, + [isRowSelected, rowKeysByGroup], + ); + + const onToggleSelectedRow = useCallback( + (groupKey: string, rowCategory: string): void => { + const rowPinKey = getRowPinKey(groupKey, rowCategory); + const isGroupSelected = selectedGroups.has(groupKey); + + if (isGroupSelected) { + setDeselectedRowsInSelectedGroups(previousDeselectedRows => { + const updated = new Set(previousDeselectedRows); + + if (updated.has(rowPinKey)) { + updated.delete(rowPinKey); + } else { + updated.add(rowPinKey); + } + + return updated; + }); + + setSelectedRows(previousSelectedRows => { + if (!previousSelectedRows.has(rowPinKey)) { + return previousSelectedRows; + } + + const updated = new Set(previousSelectedRows); + updated.delete(rowPinKey); + return updated; + }); + + return; + } + + setSelectedRows(previousSelectedRows => { + const updated = new Set(previousSelectedRows); + + if (updated.has(rowPinKey)) { + updated.delete(rowPinKey); + } else { + updated.add(rowPinKey); + } + + return updated; + }); + + setDeselectedRowsInSelectedGroups(previousDeselectedRows => { + if (!previousDeselectedRows.has(rowPinKey)) { + return previousDeselectedRows; + } + + const updated = new Set(previousDeselectedRows); + updated.delete(rowPinKey); + return updated; + }); + }, + [selectedGroups], + ); + + const selectionSnapshot = useMemo((): MinecraftGroupedStatisticTableSelectionSnapshot => { + const resolvedRowsByKey = new Map(); + + selectedGroups.forEach(groupKey => { + (groupedRowsByKey.get(groupKey) || []).forEach(row => { + const rowKey = getRowPinKey(groupKey, row.category); + + if (deselectedRowsInSelectedGroups.has(rowKey)) { + return; + } + + resolvedRowsByKey.set(rowKey, row); + }); + }); + + selectedRows.forEach(rowKey => { + const rowGroupKey = rowGroupLookup.get(rowKey); + if (rowGroupKey && selectedGroups.has(rowGroupKey) && deselectedRowsInSelectedGroups.has(rowKey)) { + return; + } + + const row = rowLookup.get(rowKey); + if (row) { + resolvedRowsByKey.set(rowKey, row); + } + }); + + const resolvedSelectedRows = Array.from(resolvedRowsByKey.entries()) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([, row]) => row); + + return { + selectedGroupKeys: Array.from(selectedGroups).sort((left, right) => left.localeCompare(right)), + selectedRowKeys: Array.from(resolvedRowsByKey.keys()).sort((left, right) => left.localeCompare(right)), + resolvedSelectedRows, + }; + }, [deselectedRowsInSelectedGroups, groupedRowsByKey, rowGroupLookup, rowLookup, selectedGroups, selectedRows]); + + useImperativeHandle( + ref, + () => ({ + getSelectionSnapshot: () => selectionSnapshot, + clearSelection: () => { + setSelectedGroups(new Set()); + setSelectedRows(new Set()); + setDeselectedRowsInSelectedGroups(new Set()); + }, + }), + [selectionSnapshot], + ); + + const prevSelectionRowKeysRef = useRef([]); + useEffect(() => { + if (!onSelectionChange) { + return; + } + + const prev = prevSelectionRowKeysRef.current; + const curr = selectionSnapshot.selectedRowKeys; + const changed = prev.length !== curr.length || curr.some((key, index) => key !== prev[index]); + + if (!changed) { + return; + } + + prevSelectionRowKeysRef.current = curr; + onSelectionChange(selectionSnapshot); + }, [onSelectionChange, selectionSnapshot]); + useEffect(() => { setSelectedSortOrder(defaultSortOrder); }, [defaultSortOrder]); @@ -367,12 +635,10 @@ export default function MinecraftGroupedStatisticTable({ useEffect(() => { const eventHandler = (event: StatisticUpdatedMessage): void => { setData(previousState => { - const isConsolidatedDataEvent = - event.id === 'consolidated_data' && - event.children_string_values && - event.children_string_values.length > 0; + const isConsolidatedDataEvent = event.id === 'consolidated_data'; const categoryMap = new Map(); + const observedCategories = new Set(); if (!isConsolidatedDataEvent) { previousState.forEach(previousRow => { @@ -400,13 +666,30 @@ export default function MinecraftGroupedStatisticTable({ if (isConsolidatedDataEvent) { processChildrenStringValues( - event.children_string_values, + event.children_string_values ?? [], categoryMap, valueLabels, prettifyNames, event.time || Date.now(), + observedCategories, ); + + nonConsolidatedTickCounterRef.current = 0; + nonConsolidatedLastEventTimeRef.current = undefined; + nonConsolidatedLastSeenTickByCategoryRef.current.clear(); } else { + const eventTime = Number.isFinite(event.time) ? event.time : undefined; + const lastEventTime = nonConsolidatedLastEventTimeRef.current; + + if (eventTime === undefined) { + nonConsolidatedTickCounterRef.current += 1; + } else if (lastEventTime === undefined || lastEventTime !== eventTime) { + nonConsolidatedTickCounterRef.current += 1; + nonConsolidatedLastEventTimeRef.current = eventTime; + } + + const currentTickCounter = Math.max(1, nonConsolidatedTickCounterRef.current); + const rawStats = statisticResolver(event, []); rawStats.forEach(stat => { @@ -414,6 +697,8 @@ export default function MinecraftGroupedStatisticTable({ return; } + observedCategories.add(stat.category); + const existing = categoryMap.get(stat.category); if (existing) { while (existing.values.length < valueLabels.length) { @@ -443,8 +728,33 @@ export default function MinecraftGroupedStatisticTable({ valueLabels, prettifyNames, event.time || Date.now(), + observedCategories, ); } + + const lastSeenByCategory = nonConsolidatedLastSeenTickByCategoryRef.current; + + observedCategories.forEach(category => { + lastSeenByCategory.set(category, currentTickCounter); + }); + + categoryMap.forEach((_, rowCategory) => { + if (observedCategories.has(rowCategory)) { + return; + } + + const lastSeenEvent = lastSeenByCategory.get(rowCategory); + if (lastSeenEvent === undefined) { + // Bootstrap rows that predate tick-based tracking without immediate removal. + lastSeenByCategory.set(rowCategory, currentTickCounter); + return; + } + + if (currentTickCounter - lastSeenEvent >= NON_CONSOLIDATED_STALE_TICK_THRESHOLD) { + categoryMap.delete(rowCategory); + lastSeenByCategory.delete(rowCategory); + } + }); } const newestData = Array.from(categoryMap.values()); @@ -471,39 +781,58 @@ export default function MinecraftGroupedStatisticTable({ validRowKeys.add(getRowPinKey(groupKey, row.category)); }); - setPinnedGroups(previousPinnedGroups => { + const handleUpdateSet = (set: Set, validKeys: Set): Set => { let changed = false; const updated = new Set(); - previousPinnedGroups.forEach(groupKey => { - if (validGroupKeys.has(groupKey)) { - updated.add(groupKey); + set.forEach(key => { + if (validKeys.has(key)) { + updated.add(key); return; } changed = true; }); - return changed ? updated : previousPinnedGroups; + return changed ? updated : set; + }; + + setPinnedGroups(previousPinnedGroups => { + return handleUpdateSet(previousPinnedGroups, validGroupKeys); }); setPinnedRows(previousPinnedRows => { - let changed = false; - const updated = new Set(); + return handleUpdateSet(previousPinnedRows, validRowKeys); + }); - previousPinnedRows.forEach(rowPinKey => { - if (validRowKeys.has(rowPinKey)) { - updated.add(rowPinKey); - return; - } + setSelectedGroups(previousSelectedGroups => { + return handleUpdateSet(previousSelectedGroups, validGroupKeys); + }); - changed = true; - }); + setSelectedRows(previousSelectedRows => { + return handleUpdateSet(previousSelectedRows, validRowKeys); + }); - return changed ? updated : previousPinnedRows; + setDeselectedRowsInSelectedGroups(previousDeselectedRows => { + return handleUpdateSet(previousDeselectedRows, validRowKeys); }); }, [data, groupKeyResolver]); + useEffect(() => { + if (!selectionEnabled || !defaultSelectAllGroups || hasInitializedDefaultSelection) { + return; + } + + if (rowKeysByGroup.size === 0) { + return; + } + + setSelectedGroups(new Set(rowKeysByGroup.keys())); + setSelectedRows(new Set()); + setDeselectedRowsInSelectedGroups(new Set()); + setHasInitializedDefaultSelection(true); + }, [defaultSelectAllGroups, hasInitializedDefaultSelection, rowKeysByGroup, selectionEnabled]); + const groupedData = useMemo((): GroupedStatisticTableGroup[] => { if (!isGroupedMode) { return []; @@ -699,11 +1028,12 @@ export default function MinecraftGroupedStatisticTable({ const renderLeafRow = (row: GroupedStatisticTableRow, rowKey: string, groupKey: string): JSX.Element => { const rowPinKey = getRowPinKey(groupKey, row.category); const isPinned = pinnedRows.has(rowPinKey); + const isSelected = isRowSelected(groupKey, rowPinKey); return ( + {selectionEnabled && ( + + onToggleSelectedRow(groupKey, row.category)} + aria-label={isSelected ? `Deselect ${row.category}` : `Select ${row.category}`} + /> + + )} {row.category} @@ -789,7 +1128,12 @@ export default function MinecraftGroupedStatisticTable({ - + + {selectionEnabled && ( + + )} {valueLabels.map(label => ( + {selectionEnabled && ( + + )}
Pin + {selectionHeaderLabel} + {keyLabel} @@ -809,10 +1153,18 @@ export default function MinecraftGroupedStatisticTable({ const isExpanded = expandedGroups.has(group.expansionKey); const isGroupExplicitlyPinned = pinnedGroups.has(group.key); const isGroupPinned = isGroupExplicitlyPinned || group.isPinnedSection; + const groupRows = groupedRowsByKey.get(group.key) || []; + const selectedGroupRowCount = groupRows.filter(row => + isRowSelected(group.key, getRowPinKey(group.key, row.category)), + ).length; + const isGroupSelected = + groupRows.length > 0 && selectedGroupRowCount === groupRows.length; + const isGroupSelectionIndeterminate = + selectedGroupRowCount > 0 && selectedGroupRowCount < groupRows.length; const rows: JSX.Element[] = [
+ onToggleSelectedGroup(group.key)} + aria-label={ + isGroupSelected + ? `Deselect group ${group.key}` + : `Select group ${group.key}` + } + /> +