diff --git a/.vscode/settings.json b/.vscode/settings.json index 0028903..530b68e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,7 +6,8 @@ { "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" + "source.fixAll.eslint": "explicit", + "source.organizeImports": "explicit" }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" diff --git a/eslint.config.mjs b/eslint.config.mjs index b122d9d..8833ddf 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,10 +5,10 @@ * terms of the MIT License as outlined in the LICENSE file. **********************************************************************************/ -import globals from 'globals'; import eslint from '@eslint/js'; -import tseslint from 'typescript-eslint'; import header from 'eslint-plugin-header'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; header.rules.header.meta.schema = false; @@ -32,13 +32,7 @@ export default tseslint.config( // ESLint Convention quotes: ['error', 'single'], semi: ['error', 'always'], - indent: [ - 'error', - 4, - { - SwitchCase: 1 - } - ], + 'block-spacing': ['error', 'always'], 'brace-style': [ 'error', diff --git a/package.json b/package.json index 8b7b1a6..adb3ef6 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@vscode/codicons": "0.0.20", "@vscode/webview-ui-toolkit": "^1.4.0", "antd": "^5.22.1", - "primeflex": "^3.3.1", + "re-resizable": "^6.11.2", "react-markdown": "^9.0.1", "remark-gfm": "^4.0.0", "throttle-debounce": "5.0.2", diff --git a/src/base/style-utils.ts b/src/base/style-utils.ts index eba3ce0..83b81f1 100644 --- a/src/base/style-utils.ts +++ b/src/base/style-utils.ts @@ -1,21 +1,23 @@ /********************************************************************************** - * Copyright (c) 2025 Company and others. + * Copyright (c) 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. **********************************************************************************/ -export function classNames(...classes: (string | Record)[]): string { +export function classNames(...classes: (string | Record | undefined)[]): string { return classes - .filter(c => c !== undefined) - .map(c => { - if (typeof c === 'string') { - return c; + .map(className => { + if (!className) { + return ''; } - - return Object.entries(c) + if (typeof className === 'string') { + return className; + } + return Object.entries(className) .filter(([, value]) => value) .map(([key]) => key); }) + .filter(className => className.length > 0) .join(' '); } diff --git a/src/label/label-helpers.tsx b/src/label/label-helpers.tsx index fb86050..0dbdba4 100644 --- a/src/label/label-helpers.tsx +++ b/src/label/label-helpers.tsx @@ -1,5 +1,5 @@ /********************************************************************************** - * Copyright (c) 2025 Company and others. + * Copyright (c) 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. @@ -8,7 +8,7 @@ import React from 'react'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { Tooltip, TooltipTrigger, TooltipContent } from '../tooltip/tooltip'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip/tooltip'; export function createHighlightedText(label?: string, highlights?: [number, number][]): React.JSX.Element { if (label === undefined) { @@ -48,7 +48,7 @@ export function createHighlightedText(label?: string, highlights?: [number, numb } export function createLabelWithTooltip(child: React.JSX.Element, tooltip?: string): React.JSX.Element { - const label =
{child}
; + const label =
{child}
; if (tooltip === undefined) { return label; diff --git a/src/tree/browser/components/cells/ActionCell.tsx b/src/tree/browser/components/cells/ActionCell.tsx new file mode 100644 index 0000000..454b9c3 --- /dev/null +++ b/src/tree/browser/components/cells/ActionCell.tsx @@ -0,0 +1,44 @@ +/********************************************************************************** + * Copyright (c) 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 React from 'react'; +import { CommandDefinition } from '../../../../vscode/webview-types'; +import { CDTTreeItem, CDTTreeItemResource, CDTTreeTableActionColumn, CDTTreeTableActionColumnCommand } from '../../../common'; + +export interface ActionCellProps { + column: CDTTreeTableActionColumn; + record: CDTTreeItem; + actions: CDTTreeTableActionColumnCommand[]; + onAction?: (event: React.UIEvent, command: CommandDefinition, value: unknown, record: CDTTreeItem) => void; +} + +const ActionCell = ({ record, actions, onAction }: ActionCellProps) => { + return ( +
+ {actions.map(action => { + const handleAction = (e: React.MouseEvent | React.KeyboardEvent) => { + e.stopPropagation(); + e.preventDefault(); + onAction?.(e, action, action.value, record); + }; + return ( + e.key === 'Enter' && handleAction(e)} + /> + ); + })} +
+ ); +}; + +export default ActionCell; diff --git a/src/tree/browser/components/cells/EditableStringCell.tsx b/src/tree/browser/components/cells/EditableStringCell.tsx new file mode 100644 index 0000000..3d233a8 --- /dev/null +++ b/src/tree/browser/components/cells/EditableStringCell.tsx @@ -0,0 +1,157 @@ +/********************************************************************************** + * Copyright (c) 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/editable-string-cell.css'; + +import { Checkbox, Input, Select } from 'antd'; +import type { CheckboxChangeEvent } from 'antd/lib/checkbox'; +import React, { useCallback, useEffect, useRef } from 'react'; +import { CDTTreeItem, CDTTreeItemResource, EditableCDTTreeTableStringColumn } from '../../../common'; +import LabelCell from './LabelCell'; + +interface EditableLabelCellProps { + column: EditableCDTTreeTableStringColumn; + record: CDTTreeItem; + editing: boolean; + autoFocus: boolean; + onSubmit: (newValue: string) => void; + onCancel: () => void; + onEdit?: (edit: boolean) => void; +} + +const EditableLabelCell = ({ + column, + record, + editing, + autoFocus, + onSubmit, + onCancel, + onEdit +}: EditableLabelCellProps) => { + const containerRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const editorRef = useRef(null); + + const commitEdit = useCallback( + (newValue: string, event?: { stopPropagation: () => void; preventDefault: () => void }) => { + event?.stopPropagation(); + event?.preventDefault(); + onSubmit(newValue); + onEdit?.(false); + }, + [onSubmit] + ); + + const cancelEdit = useCallback(() => { + onCancel(); + onEdit?.(false); + }, [column.edit.value, onCancel, onEdit]); + + // Cancel the edit only if focus leaves the entire container. + const handleBlur = useCallback(() => { + setTimeout(() => { + if (containerRef.current && document.activeElement && !containerRef.current.contains(document.activeElement)) { + cancelEdit(); + } + }, 0); + }, [cancelEdit]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + cancelEdit(); + } + e.stopPropagation(); + }, + [cancelEdit] + ); + + // Consume the double-click event so no other handler is triggered. + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onEdit?.(true); + }, + [column, onEdit] + ); + + // Focus the editor when entering edit mode. + useEffect(() => { + if (editing && editorRef.current && autoFocus) { + editorRef.current.focus(); + } + }, [editing, autoFocus]); + + if (editing) { + return ( +
+ {(() => { + switch (column.edit.type) { + case 'text': + return ( + commitEdit(e.currentTarget.value, e)} + onBlur={handleBlur} + onClick={e => e.stopPropagation()} + onKeyDown={handleKeyDown} + /> + ); + case 'boolean': { + const checked = column.edit.value === '1'; + return ( + commitEdit(e.target.checked ? '1' : '0', e)} + onBlur={handleBlur} + onClick={e => e.stopPropagation()} + onKeyDown={handleKeyDown} + /> + ); + } + case 'enum': { + return ( + + ); + } + default: + return null; + } + })()} +
+ ); + } + + return ( +
+ +
+ ); +}; + +export default React.memo(EditableLabelCell); diff --git a/src/tree/browser/components/cells/LabelCell.tsx b/src/tree/browser/components/cells/LabelCell.tsx new file mode 100644 index 0000000..3747386 --- /dev/null +++ b/src/tree/browser/components/cells/LabelCell.tsx @@ -0,0 +1,33 @@ +/********************************************************************************** + * Copyright (c) 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 classNames from 'classnames'; +import React from 'react'; +import { createHighlightedText, createLabelWithTooltip } from '../../../../label/label-helpers'; +import { CDTTreeItem, CDTTreeItemResource, CDTTreeTableStringColumn } from '../../../common'; + +export interface LabelCellProps { + column: CDTTreeTableStringColumn; + record: CDTTreeItem; +} + +const LabelCell = ({ column }: LabelCellProps) => { + const icon = column.icon && ; + + const content = column.tooltip + ? createLabelWithTooltip({createHighlightedText(column.label, column.highlight)}, column.tooltip) + : createHighlightedText(column.label, column.highlight); + + return ( +
+ {icon} + {content} +
+ ); +}; + +export default React.memo(LabelCell); diff --git a/src/tree/browser/components/cells/StringCell.tsx b/src/tree/browser/components/cells/StringCell.tsx new file mode 100644 index 0000000..64ad3e9 --- /dev/null +++ b/src/tree/browser/components/cells/StringCell.tsx @@ -0,0 +1,53 @@ +/********************************************************************************** + * Copyright (c) 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 React, { useCallback } from 'react'; +import { CDTTreeItem, CDTTreeItemResource, CDTTreeTableStringColumn, EditableCDTTreeTableStringColumn } from '../../../common'; +import EditableStringCell from './EditableStringCell'; +import LabelCell from './LabelCell'; + +interface StringCellProps { + column: CDTTreeTableStringColumn; + record: CDTTreeItem; + editing?: boolean; + autoFocus?: boolean; + onSubmit?: (record: CDTTreeItem, newValue: string) => void; + onCancel?: (record: CDTTreeItem) => void; + onEdit?: (record: CDTTreeItem, edit: boolean) => void; +} + +const StringCell = ({ + column, + record, + editing = false, + autoFocus = false, + onSubmit, + onCancel, + onEdit +}: StringCellProps) => { + const handleSubmit = useCallback((newValue: string) => onSubmit?.(record, newValue), [record, onSubmit]); + + const handleCancel = useCallback(() => onCancel?.(record), [record, onCancel]); + + const handleEdit = useCallback((edit: boolean) => onEdit?.(record, edit), [record, onEdit]); + + return column.edit && onSubmit ? ( + + ) : ( + + ); +}; + +export default StringCell; diff --git a/src/tree/browser/components/expand-icon.tsx b/src/tree/browser/components/expand-icon.tsx index b6b424b..e90c493 100644 --- a/src/tree/browser/components/expand-icon.tsx +++ b/src/tree/browser/components/expand-icon.tsx @@ -1,13 +1,13 @@ -/******************************************************************************** - * Copyright (C) 2024-2025 EclipseSource and others. +/********************************************************************************** + * Copyright (c) 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 - ********************************************************************************/ + * terms of the MIT License as outlined in the LICENSE file. + **********************************************************************************/ -import type { CDTTreeItemResource, CDTTreeItem } from '../../common/tree-model-types'; -import { classNames } from '../../../base'; import React from 'react'; +import { classNames } from '../../../base'; +import { CDTTreeItem, CDTTreeItemResource } from '../../common'; export interface RenderExpandIconProps { expanded: boolean; diff --git a/src/tree/browser/components/treetable-navigator.tsx b/src/tree/browser/components/treetable-navigator.tsx index 2dfc3ca..1eb8574 100644 --- a/src/tree/browser/components/treetable-navigator.tsx +++ b/src/tree/browser/components/treetable-navigator.tsx @@ -2,10 +2,10 @@ * 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 - ********************************************************************************/ + * terms of the MIT License as outlined in the LICENSE file. + **********************************************************************************/ -import { CDTTreeItemResource, CDTTreeItem } from '../../common'; +import { CDTTreeItem, CDTTreeItemResource } from '../../common'; export interface TreeNavigatorProps { ref: React.RefObject; diff --git a/src/tree/browser/components/utils.tsx b/src/tree/browser/components/utils.tsx index 3e999d5..bfc8812 100644 --- a/src/tree/browser/components/utils.tsx +++ b/src/tree/browser/components/utils.tsx @@ -1,11 +1,12 @@ -/******************************************************************************** - * Copyright (C) 2024-2025 EclipseSource and others. +/********************************************************************************** + * 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 - ********************************************************************************/ + * terms of the MIT License as outlined in the LICENSE file. + **********************************************************************************/ -import { CDTTreeItemResource, CDTTreeItem, CDTTreeTableStringColumn } from '../../common'; +import React, { useEffect, useRef, useState } from 'react'; +import { CDTTreeItem, CDTTreeItemResource, CDTTreeTableStringColumn } from '../../common'; /** * Recursively filters the tree to include items that match the search text @@ -114,3 +115,49 @@ export function getAncestors(item: CDTTreeItem } return ancestors; } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ClickHookHandler = (event: React.MouseEvent, payload: any) => void; + +type UseClickHookProps = { + onSingleClick?: ClickHookHandler; + onDoubleClick?: ClickHookHandler; + delay?: number; +}; + +export function useClickHook({ onSingleClick, onDoubleClick, delay = 250 }: UseClickHookProps): ClickHookHandler { + const [clicks, setClicks] = useState(0); + const [payload, setPayload] = useState(undefined); + const eventRef = useRef | null>(null); + + useEffect(() => { + let singleClickTimer: ReturnType | null = null; + + if (clicks === 1) { + // Trigger single-click after delay + singleClickTimer = setTimeout(() => { + if (clicks === 1 && onSingleClick && eventRef.current) { + onSingleClick(eventRef.current, payload); // Trigger onClick + } + setClicks(0); // Reset clicks after the delay + }, delay); + } else if (clicks === 2) { + // Trigger double-click immediately + if (onDoubleClick && eventRef.current) { + onDoubleClick(eventRef.current, payload); // Trigger onDoubleClick + } + setClicks(0); // Reset clicks immediately + } + + // Cleanup the timer on effect cleanup or if clicks change + return () => { + if (singleClickTimer) clearTimeout(singleClickTimer); + }; + }, [clicks, delay, onSingleClick, onDoubleClick]); + + return (event, payload) => { + eventRef.current = event; + setPayload(payload); + setClicks(prev => prev + 1); // Increment the click count + }; +} diff --git a/src/tree/browser/tree.tsx b/src/tree/browser/tree.tsx index cea82d9..d2d5798 100644 --- a/src/tree/browser/tree.tsx +++ b/src/tree/browser/tree.tsx @@ -8,25 +8,37 @@ import '../../../style/tree/tree-common.css'; import '../../../style/tree/tree.css'; -import { ConfigProvider, Table, TableColumnsType } from 'antd'; +import { ConfigProvider, Table } from 'antd'; import { ColumnType, ExpandableConfig } from 'antd/es/table/interface'; -import React from 'react'; +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 { findNestedValue } from '../../base'; +import { CommandDefinition } from '../../vscode/webview-types'; +import { + CDTTreeItem, + CDTTreeItemResource, + CDTTreeTableActionColumn, + CDTTreeTableActionColumnCommand, + CDTTreeTableColumnDefinition, + CDTTreeTableStringColumn, + CDTTreeWebviewContext +} from '../common/index'; +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 } from './components/utils'; -import { classNames, findNestedValue } from '../../base'; -import { - type CDTTreeItemResource, - type CDTTreeTableColumnDefinition, - type CDTTreeItem, - CDTTreeWebviewContext, - type CDTTreeTableStringColumn, - type CDTTreeTableActionColumn -} from '../common'; -import type { CommandDefinition } from '../../vscode/webview-types'; -import { createHighlightedText, createLabelWithTooltip } from '../../label/label-helpers'; +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. @@ -69,7 +81,7 @@ export type CDTTreeProps = /** * Callback to be called when a row is pinned or unpinned. */ - onPin?: (event: React.UIEvent, pinned: boolean, record: CDTTreeItem) => void; + onPin?: (event: React.UIEvent, pinned: boolean, record: CDTTreeItem, api: CDTTreeApi) => void; }; /** * Configuration for the actions of the tree table. @@ -78,17 +90,29 @@ export type CDTTreeProps = /** * Callback to be called when an action is triggered. */ - onAction?: (event: React.UIEvent, command: CommandDefinition, value: unknown, record: CDTTreeItem) => void; + 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 VS Code context menu items + // Support VSCode context menu items return (
((props, ref) => { ); }); +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] = React.useState({ width: window.innerWidth, height: window.innerHeight }); - React.useLayoutEffect(() => { + const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight }); + useLayoutEffect(() => { const updateSize = debounce(100, () => { setSize({ width: window.innerWidth, height: window.innerHeight }); }); @@ -121,9 +201,14 @@ function useWindowSize() { return size; } -export function CDTTree(props: CDTTreeProps): React.ReactElement { +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] = React.useState(); + const [globalSearchText, setGlobalSearchText] = useState(); const globalSearchRef = React.useRef(null); const autoSelectRowRef = React.useRef(false); @@ -132,7 +217,7 @@ export function CDTTree(props: CDTTreeProps): // ==== Data ==== - const filteredData = React.useMemo(() => { + const filteredData = useMemo(() => { let data = props.dataSource ?? []; if (globalSearchText) { data = filterTree(data, globalSearchText); @@ -145,7 +230,7 @@ export function CDTTree(props: CDTTreeProps): // ==== Search ==== - const onKeyDown = React.useCallback((e: React.KeyboardEvent) => { + const onKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.ctrlKey && e.key === 'f') { e.preventDefault(); e.stopPropagation(); @@ -153,18 +238,18 @@ export function CDTTree(props: CDTTreeProps): } }, []); - const onSearchShow = React.useCallback(() => setGlobalSearchText(globalSearchRef.current?.value()), []); - const onSearchHide = React.useCallback(() => { + const onSearchShow = useCallback(() => setGlobalSearchText(globalSearchRef.current?.value()), []); + const onSearchHide = useCallback(() => { setGlobalSearchText(undefined); autoSelectRowRef.current = true; }, [autoSelectRowRef]); - const onSearchChange = React.useMemo(() => debounce(300, (text: string) => setGlobalSearchText(text)), []); + const onSearchChange = useMemo(() => debounce(300, (text: string) => setGlobalSearchText(text)), []); // ==== Selection ==== - const [selection, setSelection] = React.useState(); + const [selection, setSelection] = useState(); - const selectRow = React.useCallback( + const selectRow = useCallback( (record: CDTTreeItem) => { // Single select only if (selection?.key !== record.key) { @@ -176,7 +261,7 @@ export function CDTTree(props: CDTTreeProps): // ==== Expansion ==== - const expandedRowKeys = React.useMemo(() => { + const expandedRowKeys = useMemo(() => { const expanded = new Set(props.expansion?.expandedRowKeys ?? []); if (globalSearchText) { // on search expand all nodes that match the search @@ -191,16 +276,19 @@ export function CDTTree(props: CDTTreeProps): return Array.from(expanded); }, [filteredData, globalSearchText, props.expansion?.expandedRowKeys, selection, autoSelectRowRef.current]); - const handleExpand = React.useCallback( + const handleExpand = useCallback( (expanded: boolean, record: CDTTreeItem) => { props.expansion?.onExpand?.(expanded, record); }, [props.expansion?.onExpand] ); + // ==== Edit ==== + const [editRowKey, setEditRowKey] = useState(); + // ==== Index ==== - const dataSourceIndex = React.useMemo(() => { + const dataSourceIndex = useMemo(() => { const rowIndex = new Map(); const keyIndex = new Map(); @@ -238,7 +326,7 @@ export function CDTTree(props: CDTTreeProps): [ref, dataSourceIndex.rowIndex, expandedRowKeys, handleExpand, selectRow] ); - const onTableKeyDown = React.useCallback( + const onTableKeyDown = useCallback( (event: React.KeyboardEvent) => { const selectedKey = selection?.key; if (!selectedKey) { @@ -288,141 +376,175 @@ export function CDTTree(props: CDTTreeProps): [selection, dataSourceIndex] ); - // ==== Renderers ==== + // ==== 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 renderStringColumn = React.useCallback((label: string, item: CDTTreeItem, column: CDTTreeTableStringColumn) => { - const icon = column.icon ? : null; - let content = createHighlightedText(label, column.highlight); + const handleResize = (field: string) => (_: MouseEvent | TouchEvent, width: number) => { + setColumnWidths(prev => ({ ...prev, [field]: width })); + }; - if (column.tooltip) { - content = createLabelWithTooltip({content}, column.tooltip); + 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; + }); } - - return ( -
- {icon} - {content} -
- ); + 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 renderActionColumn = React.useCallback( - (column: CDTTreeTableActionColumn | undefined, record: CDTTreeItem) => { - const actions: React.ReactNode[] = []; - - if (record.pinned !== undefined) { - actions.push( - props.pin?.onPin?.(event, !record.pinned, record)} - aria-label={record.pinned ? 'Unpin row' : 'Pin row'} - role='button' - tabIndex={0} - onKeyDown={event => { - if (event.key === 'Enter') props.pin?.onPin?.(event, !record.pinned, record); - }} - > - ); + 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; } - if (column?.commands) { - column.commands.forEach(command => { - actions.push( - props.action?.onAction?.(event, command, command.value, record)} - aria-label={command.title} - role='button' - tabIndex={0} - onKeyDown={event => { - if (event.key === 'Enter') props.action?.onAction?.(event, command, command.value, record); - }} - > - ); - }); + 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
{actions}
; + return ; }, [props.pin, props.action] ); - // ==== Columns ==== + const onSubmitEdit = useCallback( + (record: CDTTreeItem, value: string) => { + setEditRowKey(undefined); + props.edit?.onEdit?.(record, value, treeApi); + }, + [props.edit?.onEdit] + ); - const createColumns = (columnDefinitions: CDTTreeTableColumnDefinition[]): TableColumnsType> => { - function stringColumn(columnDefinition: CDTTreeTableColumnDefinition): ColumnType> { - return { - title: columnDefinition.field, - dataIndex: ['columns', columnDefinition.field, 'label'], - width: 0, - ellipsis: true, - render: (label, record) => { - const column = findNestedValue(record, ['columns', columnDefinition.field]); - - if (!column) { - return undefined; - } + const onSubmitCancel = useCallback(() => { + setEditRowKey(undefined); + }, []); - return renderStringColumn(label, record, column); - }, - onCell: record => { - const column = findNestedValue(record, ['columns', columnDefinition.field]); + const onEdit = useCallback((record: CDTTreeItem, edit: boolean) => { + if (edit) { + selectRow(record); + setEditRowKey(record.key); + } else { + setEditRowKey(undefined); + } + }, []); - if (!column) { - return {}; - } + const isEditing = useCallback( + (column: CDTTreeTableStringColumn, record: CDTTreeItem) => + editRowKey === record.key || column.edit?.type === 'boolean' || column.edit?.type === 'enum', + [editRowKey] + ); - const colSpan = column.colSpan; - if (colSpan) { - return { - colSpan: colSpan === 'fill' ? columnDefinitions.length : colSpan, - style: { - zIndex: 1 - } - }; - } + const renderStringCell = useCallback( + (column: CDTTreeTableStringColumn | undefined, record: CDTTreeItem) => { + if (!column) { + return undefined; + } - return {}; - } - }; - } + return ( + + ); + }, + [editRowKey] + ); - function actionColumn(columnDefinition: CDTTreeTableColumnDefinition): ColumnType> { - return { - title: columnDefinition.field, - dataIndex: ['columns', columnDefinition.field], - width: 16 * 5, - render: renderActionColumn - }; - } + const columns = useMemo(() => { + return ( + props.columnDefinitions?.map>>(colDef => { + const resizeable: ResizeableCell = { + resizeable: colDef.resizable, + maxWidth: availableWidth, + onDidColumnResize: handleResize(colDef.field) + }; - return [ - ...(columnDefinitions?.map(c => { - if (c.type === 'string') { - return stringColumn(c); - } else if (c.type === 'action') { - return actionColumn(c); + 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 { - title: c.field, - dataIndex: ['columns', c.field, 'label'], + ...resizeable, + title: colDef.field, + dataIndex: ['columns', colDef.field, 'label'], width: 200 }; - }) ?? []) - ]; - }; - - const columns = React.useMemo(() => createColumns(props.columnDefinitions ?? []), [props.columnDefinitions]); + }) ?? [] + ); + }, [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 - React.useEffect(() => { + useEffect(() => { if (!ref.current) { return; } @@ -436,7 +558,7 @@ export function CDTTree(props: CDTTreeProps): if (!selectedRow) { // Selected row was removed from the DOM, focus on the table ref.current?.focus(); - } else if (selectedRow !== document.activeElement) { + } else if (selectedRow !== document.activeElement && !selectedRow.contains(document.activeElement)) { // Selected row is still in the DOM, but not focused selectedRow?.focus(); } @@ -447,7 +569,7 @@ export function CDTTree(props: CDTTreeProps): }, [ref.current]); // Abort scrolling when mouse drag was finished (e.g., left mouse button is no longer pressed) outside the iframe - React.useEffect(() => { + useEffect(() => { const abortScroll = (event: MouseEvent) => { if (!(event.buttons & 1)) { // left button is no longer pressed... @@ -463,26 +585,37 @@ export function CDTTree(props: CDTTreeProps): }, []); // Scroll to selected row if autoSelectRowRef is set - React.useEffect(() => { + useEffect(() => { if (autoSelectRowRef.current && selection) { tblRef.current?.scrollTo({ key: selection.key }); autoSelectRowRef.current = false; } }, [autoSelectRowRef.current]); - const onRowClick = React.useCallback( - (record: CDTTreeItem, event: React.MouseEvent) => { + const onRowSingleClick = useCallback( + (_event: React.MouseEvent, record: CDTTreeItem) => { const isExpanded = expandedRowKeys?.includes(record.id); handleExpand(!isExpanded, record); selectRow(record); - - event.currentTarget.focus(); }, - [props.expansion] + [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 (
@@ -498,7 +631,7 @@ export function CDTTree(props: CDTTreeProps): ref={tblRef} columns={columns} dataSource={filteredData} - components={{ body: { row: BodyRow } }} + components={{ body: { row: BodyRow, cell: BodyCell } }} virtual scroll={{ x: width, y: height - 2 }} showHeader={false} @@ -511,7 +644,7 @@ export function CDTTree(props: CDTTreeProps): } onRow={record => ({ record, - onClick: event => onRowClick(record, event) + onClick: event => onRowClick(event, record) })} expandable={{ expandIcon: props => , @@ -524,4 +657,4 @@ export function CDTTree(props: CDTTreeProps):
); -} +}; diff --git a/src/tree/common/tree-converter.ts b/src/tree/common/tree-converter.ts index eb7cb1d..ec48d4a 100644 --- a/src/tree/common/tree-converter.ts +++ b/src/tree/common/tree-converter.ts @@ -5,7 +5,7 @@ * terms of the MIT License as outlined in the LICENSE File ********************************************************************************/ -import type { CDTTreeItemResource, CDTTreeItem } from './tree-model-types'; +import type { CDTTreeItem, CDTTreeItemResource } from './tree-model-types'; /** * A TreeConverterContext is used to pass additional information to the TreeResourceConverter. @@ -27,7 +27,11 @@ export interface CDTTreeConverterContext; + assignedResources: Record; + /** + * A map of all items that are currently in the tree. + */ + assignedItems: Record | undefined>; } /** diff --git a/src/tree/common/tree-messenger-types.ts b/src/tree/common/tree-messenger-types.ts index 8e90bfe..061e927 100644 --- a/src/tree/common/tree-messenger-types.ts +++ b/src/tree/common/tree-messenger-types.ts @@ -8,15 +8,7 @@ import type { NotificationType } from 'vscode-messenger-common'; import type { CDTTreeExtensionModel } from './tree-model-types'; -export interface CDTTreeNotificationContext { - /** - * If true or undefined, the tree will be resynced. - */ - resync?: boolean; -} - export interface CDTTreeNotification { - context?: CDTTreeNotificationContext; data: T; } @@ -34,8 +26,19 @@ export interface CDTTreeExecuteCommand { value?: unknown; } +export interface CDTTreePartialUpdate { + items?: TItem[]; +} + export namespace CDTTreeMessengerType { + /** + * Replace the current state with the given state. + */ export const updateState: NotificationType = { method: 'updateState' }; + /** + * Update the nodes with the given nodes. + */ + export const updatePartial: NotificationType = { method: 'updatePartial' }; export const ready: NotificationType = { method: 'ready' }; export const executeCommand: NotificationType> = { method: 'executeCommand' }; diff --git a/src/tree/common/tree-model-types.ts b/src/tree/common/tree-model-types.ts index cf5b272..19a8e63 100644 --- a/src/tree/common/tree-model-types.ts +++ b/src/tree/common/tree-model-types.ts @@ -1,12 +1,12 @@ -/******************************************************************************** - * Copyright (C) 2024-2025 EclipseSource, Arm Limited and others. +/********************************************************************************** + * Copyright (c) 2024-2025 EclipseSource, Arm Limited and others. * * This program and the accompanying materials are made available under the - * terms of the MIT License as outlined in the LICENSE File - ********************************************************************************/ + * terms of the MIT License as outlined in the LICENSE file. + **********************************************************************************/ import { VSCodeContext } from '../../vscode/webview-types'; -import type { CDTTreeTableColumn, CDTTreeTableColumnDefinition } from './tree-table-column-types'; +import { CDTTreeTableColumn, CDTTreeTableColumnDefinition } from './tree-table-column-types'; // ==== Items ==== @@ -82,10 +82,13 @@ export interface CDTTreeExtensionModel { * The view model that is used to update the CDT tree view. * It is the actual model that is used to render the tree view. */ -export interface CDTTreeModel { +export interface CDTTreeViewModel { + root: CDTTreeItem; items: CDTTreeItem[]; expandedKeys: string[]; pinnedKeys: string[]; + references: Record | undefined>; + resources: Record; } export interface CDTTreeWebviewContext { diff --git a/src/tree/common/tree-table-column-types.ts b/src/tree/common/tree-table-column-types.ts index d32a7d3..7f07175 100644 --- a/src/tree/common/tree-table-column-types.ts +++ b/src/tree/common/tree-table-column-types.ts @@ -7,21 +7,6 @@ import type { CommandDefinition } from '../../vscode/webview-types'; -/** - * A column definition for a tree table. - * This is used to define the columns that are displayed in the tree table. - */ -export interface CDTTreeTableColumnDefinition { - /** - * The type of the column. It can be used to show different types of columns. - */ - type: string; - /** - * The field that is used to get the value for this column. See {@link CDTTreeItem.columns}. - */ - field: string; -} - /** * A string column represents a column that displays a string value. */ @@ -38,8 +23,49 @@ export interface CDTTreeTableStringColumn { * The tooltip that is displayed when hovering over the string. */ tooltip?: string; + /** + * If the column is editable, this property contains the data that is used to provide proper UI for it. + */ + edit?: EditableData; } +/** + * An editable column represents a column that allows to edit a string value. + */ +export interface EditableCDTTreeTableStringColumn extends CDTTreeTableStringColumn { + /** + * Contains the data that is used to provide proper UI for it. + */ + edit: EditableData; +} + +export interface EditableCellData { + type: string; +} + +export interface EditableTextData extends EditableCellData { + type: 'text'; + value: string; +} + +export interface EditableEnumData extends EditableCellData { + type: 'enum'; + options: EditableEnumDataOption[]; + value: string; +} + +export interface EditableEnumDataOption { + label: string; + value: string; +} + +export interface EditableBooleanData extends EditableCellData { + type: 'boolean'; + value: '0' | '1'; +} + +export type EditableData = EditableTextData | EditableEnumData | EditableBooleanData; + /** * An action column represents a column that displays multiple interactable buttons/icons. */ @@ -59,3 +85,26 @@ export interface CDTTreeTableActionColumnCommand extends CommandDefinition { } export type CDTTreeTableColumn = CDTTreeTableStringColumn | CDTTreeTableActionColumn; + +export type CDTTreeTableColumnTypes = CDTTreeTableStringColumn['type'] | CDTTreeTableActionColumn['type']; + +/** + * A column definition for a tree table. + * This is used to define the columns that are displayed in the tree table. + */ +export interface CDTTreeTableColumnDefinition { + /** + * The type of the column. It can be used to show different types of columns. + */ + type: CDTTreeTableColumnTypes; + /** + * The field that is used to get the value for this column. See {@link CDTTreeItem.columns}. + */ + field: string; + /** + * Whether the column is resizable. Default is false. + * The resize handle is display on the right side. + * That means the column after this column is also resized. + */ + resizable?: boolean; +} diff --git a/src/tree/vscode/tree-data-provider.ts b/src/tree/vscode/tree-data-provider.ts index dd4ec65..cf1fcbc 100644 --- a/src/tree/vscode/tree-data-provider.ts +++ b/src/tree/vscode/tree-data-provider.ts @@ -21,16 +21,8 @@ import type { MaybePromise } from '../../base/utils'; * are actually send to the webview to be displayed in the tree. */ export interface CDTTreeDataProvider { - /** - * An event that is fired when the tree is disposed / terminated. - */ onDidTerminate: vscode.Event>; - - /** - * An event that is fired when the tree data changes. - */ onDidChangeTreeData: vscode.Event>; - /** * Get the column definitions for the tree table. */ @@ -42,7 +34,7 @@ export interface CDTTreeDataProvider { getSerializedRoots(): MaybePromise; /** - * Get the serialization of the given element. + * Get the children of the given element. */ getSerializedData(element: TNode): MaybePromise; } diff --git a/src/tree/vscode/tree-webview-view-provider.ts b/src/tree/vscode/tree-webview-view-provider.ts index f8f09b3..91cbd66 100644 --- a/src/tree/vscode/tree-webview-view-provider.ts +++ b/src/tree/vscode/tree-webview-view-provider.ts @@ -96,12 +96,18 @@ export abstract class CDTTreeWebviewViewProvider implements vscode.Webvie const disposables = [ this.dataProvider.onDidTerminate(async event => { if (event.remaining > 0) { - this.refresh(); + this.refreshFull(); } }), this.dataProvider.onDidChangeTreeData(async event => { - if (event.context?.resync !== false) { - this.refresh(); + if (event.data) { + if (Array.isArray(event.data)) { + await this.refreshPartial(event.data); + } else { + await this.refreshPartial([event.data]); + } + } else { + this.refreshFull(); } }), this.messenger.onNotification(CDTTreeMessengerType.ready, () => this.onReady(), { sender: participant }), @@ -120,10 +126,10 @@ export abstract class CDTTreeWebviewViewProvider implements vscode.Webvie } protected async onReady(): Promise { - await this.refresh(); + await this.refreshFull(); } - protected async refresh(): Promise { + protected async refreshFull(): Promise { if (!this.participant) { return; } @@ -134,6 +140,16 @@ export abstract class CDTTreeWebviewViewProvider implements vscode.Webvie this.sendNotification(CDTTreeMessengerType.updateState, { columnFields, items }); } + protected async refreshPartial(nodes: TNode[]): Promise { + if (!this.participant) { + return; + } + + const items = await Promise.all(nodes.map(async node => this.dataProvider.getSerializedData(node))); + + this.sendNotification(CDTTreeMessengerType.updatePartial, { items }); + } + public sendNotification

(type: NotificationType

, params?: P): void { if (this.participant) { this.messenger.sendNotification(type, this.participant, params); diff --git a/style/tree/editable-string-cell.css b/style/tree/editable-string-cell.css new file mode 100644 index 0000000..9ffbdb1 --- /dev/null +++ b/style/tree/editable-string-cell.css @@ -0,0 +1,117 @@ +/********************************************************************* + * Copyright (c) 2024 Arm Limited and others + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + *********************************************************************/ + +.ant-input.text-field-cell { + background: var(--ant-color-bg-container); + color: var(--ant-color-text); + font-family: var(--ant-font-family-code); + font-size: var(--ant-table-cell-font-size); + line-height: var(--ant-line-height); + padding: 1px; + padding-left: 3px; + border-radius: 0; + border-style: dotted; +} + +.css-var-r0.ant-select-css-var { + --ant-control-height: auto; + --ant-color-text-placeholder: var(--vscode-sideBar-foreground); + --ant-select-show-arrow-padding-inline-end: 2em; + --ant-border-radius-sm: 0; + --ant-border-radius-lg: 0; + --ant-select-option-active-bg: var(--vscode-list-hoverBackground); + --ant-select-option-font-size: 12px; + --ant-select-option-selected-bg: var(--vscode-list-inactiveSelectionBackground); + --ant-select-option-selected-color: var(--vscode-sideBar-foreground); + --ant-select-option-height: auto; + --ant-select-option-padding: 3px 6px; +} + +.enum-field-cell.ant-select-outlined:not(.ant-select-customize-input) .ant-select-selector { + background: var(--ant-color-bg-container); + color: var(--ant-color-text); + font-family: var(--ant-font-family-code); + font-size: var(--ant-table-cell-font-size); + line-height: var(--ant-line-height); + padding: 1px; + padding-left: 3px; + border-radius: 0; + border-style: dotted; +} + +.ant-select-dropdown { + background: var(--ant-color-bg-container); + color: var(--ant-color-text); + font-family: var(--ant-font-family-code); + font-size: var(--ant-table-cell-font-size); + line-height: var(--ant-line-height); + padding: 1px; + padding-left: 3px; +} + +.enum-field-cell.ant-select-outlined:not(.ant-select-customize-input) .ant-select-arrow { + color: var(--ant-color-text); +} + +.editable-string-cell { + cursor: pointer; + display: contents; +} + +.editable-string-cell .tree-label>span>span { + border-bottom: 1px dotted; +} + +.edit-field-container, +.editable-string-cell { + font-family: var(--ant-font-family-code); + font-style: normal; + filter: unset; + opacity: 1; +} + +.ant-table-cell.value>.tree-cell { + font-family: var(--ant-font-family-code); + opacity: 0.8; +} + +.edit-field-container { + display: contents; + flex-grow: 1; +} + +.edit-field-container>* { + flex-grow: 1; +} + +.edit-field-container .ant-select { + display: contents; + --ant-font-size-icon: 10px; +} + +.ant-table-cell.value .tree-cell, +.ant-table-cell.value .ant-checkbox-wrapper { + padding-left: 4px; +} + +.ant-select-selector::before { + content: ''; + position: absolute; + bottom: 0; + left: 4px; + /* Indent from the left */ + width: calc(100% - 4px); + /* Make the dotted border span the remaining width */ + border-bottom: 1px dotted; +} + +.ant-select-selector { + border: 0 !important; +} \ No newline at end of file diff --git a/style/tree/tree.css b/style/tree/tree.css index e96fee2..afd4ff7 100644 --- a/style/tree/tree.css +++ b/style/tree/tree.css @@ -6,7 +6,8 @@ ********************************************************************************/ .markdown { - line-break: anywhere; + word-wrap: break-word; + word-break: break-word; } .markdown hr { @@ -115,3 +116,16 @@ .ant-table .ant-table-tbody-virtual-scrollbar-horizontal { display: none; } + +.ant-table .resizable-handle { + border-left: var(--vscode-sideBarSectionHeader-border) 2px solid; +} + +.ant-table .resizable-handle:hover, +.ant-table .resizable-handle:active { + border-color: var(--vscode-sash-hoverBorder); +} + +.ant-table .ant-table-row.ant-table-row-resizing .tree-actions { + display: none; +} diff --git a/yarn.lock b/yarn.lock index b643832..8cfe76c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2019,6 +2019,11 @@ prettier@^3.5.2: resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.5.2.tgz#d066c6053200da0234bf8fa1ef45168abed8b914" integrity sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg== +primeflex@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/primeflex/-/primeflex-3.3.1.tgz#361dddf6eb5db50d733e4cddd4b6e376a3d7bd68" + integrity sha512-zaOq3YvcOYytbAmKv3zYc+0VNS9Wg5d37dfxZnveKBFPr7vEIwfV5ydrpiouTft8MVW6qNjfkaQphHSnvgQbpQ== + property-information@^6.0.0: version "6.5.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.5.0.tgz#6212fbb52ba757e92ef4fb9d657563b933b7ffec" @@ -2385,6 +2390,11 @@ rc-virtual-list@^3.14.2, rc-virtual-list@^3.5.1, rc-virtual-list@^3.5.2: rc-resize-observer "^1.0.0" rc-util "^5.36.0" +re-resizable@^6.11.2: + version "6.11.2" + resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.11.2.tgz#2e8f7119ca3881d5b5aea0ffa014a80e5c1252b3" + integrity sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A== + react-is@^18.2.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"