diff --git a/src/components/tree/components/search-overlay.tsx b/src/components/tree/components/search-overlay.tsx new file mode 100644 index 0000000..0c37750 --- /dev/null +++ b/src/components/tree/components/search-overlay.tsx @@ -0,0 +1,86 @@ +/********************************************************************* + * Copyright (c) 2024 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 + ********************************************************************************/ + +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'; +import React from 'react'; +import './search.css'; + +export interface SearchOverlayProps { + onChange?: (text: string) => void; + onShow?: () => void; + onHide?: () => void; +} + +export interface SearchOverlay { + focus: () => void; + value(): string; + setValue: (value: string) => void; + show: () => void; + hide: () => void; +} + +export const SearchOverlay = React.forwardRef((props, ref) => { + const [showSearch, setShowSearch] = React.useState(false); + const searchTextRef = React.useRef(null); + const previousFocusedElementRef = React.useRef(null); + + const show = () => { + previousFocusedElementRef.current = document.activeElement as HTMLElement; + setShowSearch(true); + setTimeout(() => searchTextRef.current?.select(), 100); + props.onShow?.(); + }; + + const hide = () => { + setShowSearch(false); + props.onHide?.(); + if (previousFocusedElementRef.current) { + previousFocusedElementRef.current.focus(); + } + }; + + const onTextChange = (e: React.ChangeEvent) => { + const value = e.target.value; + props.onChange?.(value); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.ctrlKey && e.key === 'f') { + e.preventDefault(); + e.stopPropagation(); + show(); + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + hide(); + } + }; + + const onFocus = (e: React.FocusEvent) => { + if (e.relatedTarget) { + previousFocusedElementRef.current = e.relatedTarget as HTMLElement; + } + }; + + React.useImperativeHandle(ref, () => ({ + focus: () => searchTextRef.current?.focus(), + value: () => searchTextRef.current?.value ?? '', + setValue: (newValue: string) => { + if (searchTextRef.current) { + searchTextRef.current.value = newValue; + } + }, + show: () => show(), + hide: () => hide() + })); + + return (
+ + hide()} /> +
+ ); +}); diff --git a/src/components/tree/components/search.css b/src/components/tree/components/search.css new file mode 100644 index 0000000..b804d0a --- /dev/null +++ b/src/components/tree/components/search.css @@ -0,0 +1,84 @@ +/******************************************************************************** + * Copyright (C) 2024 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 + ********************************************************************************/ + +.search-overlay { + position: fixed; + top: -33px; + opacity: 0; + right: 20px; + background-color: var(--vscode-editorWidget-background); + box-shadow: 0 0 4px 1px var(--vscode-widget-shadow); + color: var(--vscode-editorWidget-foreground); + border-bottom: 1px solid var(--vscode-widget-border); + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-left: 1px solid var(--vscode-widget-border); + border-right: 1px solid var(--vscode-widget-border); + box-sizing: border-box; + height: 33px; + line-height: 19px; + overflow: hidden; + padding: 4px; + z-index: 35; + display: flex; + flex-direction: row; + gap: 5px; + + -webkit-transition: top 0.2s ease, opacity 0.2s ease; + -moz-transition: top 0.2s ease, opacity 0.2s ease; + -ms-transition: top 0.2s ease, opacity 0.2s ease; + -o-transition: top 0.2s ease, opacity 0.2s ease; + transition: top 0.2s ease, opacity 0.2s ease; +} + +.search-overlay.visible { + top: 5px; + opacity: 1; +} + +body.has-scrollbar .search-overlay { + right: 5px; +} + +.search-overlay .search-input { + color: var(--vscode-input-foreground); + background-color: var(--vscode-input-background); + outline: none; + scrollbar-width: none; + border: none; + box-sizing: border-box; + display: inline-block; + font-family: inherit; + font-size: inherit; + height: 100%; + line-height: inherit; + resize: none; + width: 100%; + padding: 4px 6px; + margin: 0; +} + +.search-overlay input.search-input:focus { + outline: 1px solid var(--vscode-focusBorder) +} + + +.search-input::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.search-input::-moz-placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.search-input:-ms-input-placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.search-input:-webkit-input-placeholder { + color: var(--vscode-input-placeholderForeground); +} \ No newline at end of file diff --git a/src/components/tree/components/tree.tsx b/src/components/tree/components/tree.tsx index 2d97f8e..1c4610a 100644 --- a/src/components/tree/components/tree.tsx +++ b/src/components/tree/components/tree.tsx @@ -14,6 +14,8 @@ import { classNames } from 'primereact/utils'; import React, { useEffect, useState } from 'react'; import { useCDTTreeContext } from '../tree-context'; import { CDTTreeItem, CTDTreeMessengerType, CTDTreeWebviewContext } from '../types'; +import { SearchOverlay } from './search-overlay'; + import { createActions, createHighlightedText, createLabelWithTooltip } from './utils'; import { ProgressBar } from 'primereact/progressbar'; @@ -28,18 +30,27 @@ const PROGRESS_BAR_HIDE_DELAY = 200; export const ComponentTree = (props: ComponentTreeProps) => { const treeContext = useCDTTreeContext(); const [showProgressBar, setShowProgressBar] = useState(false); + const [filter, setFilter] = React.useState(); + const searchRef = React.useRef(null); + + useEffect(() => { + // Slightly delay showing/hiding the progress bar to avoid flickering + const timer = setTimeout(() => setShowProgressBar(props.isLoading), PROGRESS_BAR_HIDE_DELAY); + return () => clearTimeout(timer); + }, [props.isLoading]); useEffect(() => { - if (!props.isLoading) { - // Delay hiding the progress bar to allow the animation to complete - const timer = setTimeout(() => { - setShowProgressBar(false); - }, PROGRESS_BAR_HIDE_DELAY); - return () => clearTimeout(timer); + if (document.documentElement.scrollHeight > document.documentElement.clientHeight) { + document.body.classList.add('has-scrollbar'); } else { - setShowProgressBar(true); + document.body.classList.remove('has-scrollbar'); } - }, [props.isLoading]); + }); + + // Assemble the tree + if (props.nodes === undefined) { + return
loading
; + } // Assemble the tree if (props.nodes === undefined) { @@ -52,7 +63,6 @@ export const ComponentTree = (props: ComponentTreeProps) => { return
No children provided
; } - // Event handler const onToggle = async (event: TreeEventNodeEvent) => { if (event.node.leaf) { @@ -70,9 +80,9 @@ export const ComponentTree = (props: ComponentTreeProps) => { const nodeTemplate = (node: TreeNode) => { CDTTreeItem.assert(node); return
- {createLabelWithTooltip(createHighlightedText(node.label, node.options?.highlights), node.options?.tooltip)} + {createLabelWithTooltip(createHighlightedText(node.label, node.data.options?.highlights), node.data.options?.tooltip)} {createActions(treeContext, node)}
; }; @@ -87,12 +97,25 @@ export const ComponentTree = (props: ComponentTreeProps) => { ; }; - return
+ const onKeyDown = (e: React.KeyboardEvent) => { + if (e.ctrlKey && e.key === 'f') { + e.preventDefault(); + e.stopPropagation(); + searchRef.current?.show(); + } + }; + + const onSearchShow = () => setFilter(searchRef.current?.value()); + const onSearchHide = () => setFilter(undefined); + const onSearchChange = (text: string) => setFilter(text); + + return
{showProgressBar && }
+ { onNodeClick={event => onClick(event)} onExpand={event => onToggle(event)} onCollapse={event => onToggle(event)} + filter={true} + filterMode='strict' + filterValue={filter} + onFilterValueChange={() => { /* needed as otherwise the filter value is not taken into account */ }} + showHeader={false} /> -
; +
; }; diff --git a/src/components/tree/components/treetable.tsx b/src/components/tree/components/treetable.tsx index a1e3746..27b9b17 100644 --- a/src/components/tree/components/treetable.tsx +++ b/src/components/tree/components/treetable.tsx @@ -16,6 +16,7 @@ import React, { useEffect, useState } from 'react'; import { useCDTTreeContext } from '../tree-context'; import { CDTTreeItem, CDTTreeTableColumnDefinition, CDTTreeTableExpanderColumn, CDTTreeTableStringColumn, CTDTreeMessengerType, CTDTreeWebviewContext } from '../types'; import { createActions, createHighlightedText, createIcon, createLabelWithTooltip } from './utils'; +import { SearchOverlay } from './search-overlay'; import { ProgressBar } from 'primereact/progressbar'; export type ComponentTreeTableProps = { @@ -30,18 +31,22 @@ const PROGRESS_BAR_HIDE_DELAY = 200; export const ComponentTreeTable = (props: ComponentTreeTableProps) => { const treeContext = useCDTTreeContext(); const [showProgressBar, setShowProgressBar] = useState(false); + const [filter, setFilter] = React.useState(); + const searchRef = React.useRef(null); useEffect(() => { - if (!props.isLoading) { - // Delay hiding the progress bar to allow the animation to complete - const timer = setTimeout(() => { - setShowProgressBar(false); - }, PROGRESS_BAR_HIDE_DELAY); - return () => clearTimeout(timer); + // Slightly delay showing/hiding the progress bar to avoid flickering + const timer = setTimeout(() => setShowProgressBar(props.isLoading), PROGRESS_BAR_HIDE_DELAY); + return () => clearTimeout(timer); + }, [props.isLoading]); + + useEffect(() => { + if (document.documentElement.scrollHeight > document.documentElement.clientHeight) { + document.body.classList.add('has-scrollbar'); } else { - setShowProgressBar(true); + document.body.classList.remove('has-scrollbar'); } - }, [props.isLoading]); + }); // Assemble the treetable if (props.nodes === undefined) { @@ -54,7 +59,6 @@ export const ComponentTreeTable = (props: ComponentTreeTableProps) => { return
No children provided
; } - // Event handler const onToggle = (event: TreeTableEvent) => { if (event.node.leaf) { @@ -72,7 +76,7 @@ export const ComponentTreeTable = (props: ComponentTreeTableProps) => { const template = (node: TreeNode, field: string) => { CDTTreeItem.assert(node); - const column = node.columns?.[field]; + const column = node.data.columns?.[field]; if (column?.type === 'expander') { return expanderTemplate(node, column); @@ -86,7 +90,7 @@ export const ComponentTreeTable = (props: ComponentTreeTableProps) => { const expanderTemplate = (node: TreeNode, column: CDTTreeTableExpanderColumn) => { CDTTreeItem.assert(node); - return
{ const text = createHighlightedText(column.label, column.highlight); return
{createLabelWithTooltip(text, column.tooltip)}
; @@ -126,12 +130,25 @@ export const ComponentTreeTable = (props: ComponentTreeTableProps) => { const expandedState = getExpandedState(props.nodes); const selectedKey = props.selectedNode ? props.selectedNode.key as string : undefined; - return
+ const onKeyDown = (e: React.KeyboardEvent) => { + if (e.ctrlKey && e.key === 'f') { + e.preventDefault(); + e.stopPropagation(); + searchRef.current?.show(); + } + }; + + const onSearchShow = () => setFilter(searchRef.current?.value()); + const onSearchHide = () => setFilter(undefined); + const onSearchChange = (text: string) => setFilter(text); + + return
{showProgressBar && }
+ { onExpand={event => onToggle(event)} onCollapse={event => onToggle(event)} onRowClick={event => onClick(event)} + filterMode='strict' // continue searching on children + globalFilter={filter} > {props.columnDefinitions?.map(c => { - return template(node, c.field)} expander={c.expander} />; + return template(node, c.field)} expander={c.expander} filter={true} />; })} - +
; }; diff --git a/src/components/tree/components/utils.tsx b/src/components/tree/components/utils.tsx index bada0d1..446b9f7 100644 --- a/src/components/tree/components/utils.tsx +++ b/src/components/tree/components/utils.tsx @@ -46,7 +46,7 @@ export function createHighlightedText(label?: string, highlights?: [number, numb export function createLabelWithTooltip(child: React.JSX.Element, tooltip?: string): React.JSX.Element { const label =
{child} -
; +
; if (tooltip === undefined) { return label; @@ -72,7 +72,7 @@ export function createActions(context: CDTTreeContext, node: TreeNode): React.JS }; return
- {node.options?.commands?.map(a => onClick(event, a)}>)} + {node.data.options?.commands?.map(a => onClick(event, a)}>)}
; } diff --git a/src/components/tree/types.ts b/src/components/tree/types.ts index 3ce9f32..d782826 100644 --- a/src/components/tree/types.ts +++ b/src/components/tree/types.ts @@ -30,14 +30,22 @@ export interface CDTTreeTableStringColumn { tooltip?: string; } -export interface CDTTreeItem extends PrimeTreeNode { - __type: 'CDTTreeItem' - id: string; - key: string; - icon?: string; +export interface CDTTreeItemData { path: string[]; options?: CDTTreeOptions; columns?: Record; +} + +export interface CDTTreeItemOptions extends Omit { + id: string; /* we require the user to provide an id */ + key?: string; /* we only allow string keys */ + icon?: string; /* we need to provide icons as string as they cannot be serialized otherwise */ + children?: CDTTreeItem[]; /* children are typed to our own tree items */ +} + +export interface CDTTreeItem extends CDTTreeItemOptions { + __type: 'CDTTreeItem' + data: CDTTreeItemData; children?: CDTTreeItem[]; } @@ -52,10 +60,13 @@ export namespace CDTTreeItem { } } - export function create(options: Omit): CDTTreeItem { + export function create(props: CDTTreeItemOptions & CDTTreeItemData): CDTTreeItem { + const { path, options, columns, ...itemProps } = props; return { __type: 'CDTTreeItem', - ...options + ...itemProps, + key: props.key ?? itemProps.id, + data: { path, options, columns } }; } } diff --git a/src/plugin/peripheral/nodes/message-node.ts b/src/plugin/peripheral/nodes/message-node.ts index ec393f7..998943c 100644 --- a/src/plugin/peripheral/nodes/message-node.ts +++ b/src/plugin/peripheral/nodes/message-node.ts @@ -36,7 +36,6 @@ export class MessageNode extends PeripheralBaseNode { public getCDTTreeItem(): CDTTreeItem { return CDTTreeItem.create({ id: this.getId(), - key: this.getId(), path: this.getId().split(PERIPHERAL_ID_SEP), }); } diff --git a/src/plugin/peripheral/nodes/peripheral-field-node.ts b/src/plugin/peripheral/nodes/peripheral-field-node.ts index b8aa962..e34baed 100644 --- a/src/plugin/peripheral/nodes/peripheral-field-node.ts +++ b/src/plugin/peripheral/nodes/peripheral-field-node.ts @@ -12,8 +12,8 @@ import { CommandDefinition, NodeSetting, NumberFormat } from '../../../common'; import { Commands } from '../../../manifest'; import { binaryFormat, hexFormat, parseInteger } from '../../../utils'; import { PERIPHERAL_ID_SEP, PeripheralBaseNode } from './base-node'; -import { PeripheralRegisterNode } from './peripheral-register-node'; import { CDTTreeItem } from '../../../components/tree/types'; +import { PeripheralRegisterNode } from './peripheral-register-node'; export type PeripheralFieldNodeContextValue = 'field' | 'field-res' | 'fieldRO' | 'fieldWO' @@ -121,7 +121,6 @@ export class PeripheralFieldNode extends PeripheralBaseNode { return CDTTreeItem.create({ id: this.getId(), - key: this.getId(), label: this.getLabel(), leaf: true, path: this.getId().split(PERIPHERAL_ID_SEP), diff --git a/src/plugin/peripheral/nodes/peripheral-node.ts b/src/plugin/peripheral/nodes/peripheral-node.ts index da2cd7a..9de891d 100644 --- a/src/plugin/peripheral/nodes/peripheral-node.ts +++ b/src/plugin/peripheral/nodes/peripheral-node.ts @@ -14,8 +14,8 @@ import { MemUtils } from '../../../memreadutils'; import { hexFormat } from '../../../utils'; import { PERIPHERAL_ID_SEP, PeripheralBaseNode } from './base-node'; import { PeripheralClusterNode, PeripheralRegisterOrClusterNode } from './peripheral-cluster-node'; -import { PeripheralRegisterNode } from './peripheral-register-node'; import { CDTTreeItem } from '../../../components/tree/types'; +import { PeripheralRegisterNode } from './peripheral-register-node'; export type PeripheralNodeContextValue = 'peripheral' | 'peripheral.pinned' @@ -100,7 +100,6 @@ export class PeripheralNode extends PeripheralBaseNode { public getCDTTreeItem(): CDTTreeItem { return CDTTreeItem.create({ id: this.getId(), - key: this.getId(), label: this.getLabel(), icon: this.pinned ? 'codicon codicon-pinned' : undefined, expanded: this.expanded, diff --git a/src/plugin/peripheral/nodes/peripheral-register-node.ts b/src/plugin/peripheral/nodes/peripheral-register-node.ts index 69a4bb7..9df0081 100644 --- a/src/plugin/peripheral/nodes/peripheral-register-node.ts +++ b/src/plugin/peripheral/nodes/peripheral-register-node.ts @@ -150,7 +150,6 @@ export class PeripheralRegisterNode extends ClusterOrRegisterBaseNode { const labelValue = this.getLabelValue(); return CDTTreeItem.create({ id: this.getId(), - key: this.getId(), label: this.getLabel(), expanded: this.expanded, path: this.getId().split(PERIPHERAL_ID_SEP), diff --git a/src/plugin/peripheral/tree/peripheral-session-tree.ts b/src/plugin/peripheral/tree/peripheral-session-tree.ts index 1bca1d3..602d453 100644 --- a/src/plugin/peripheral/tree/peripheral-session-tree.ts +++ b/src/plugin/peripheral/tree/peripheral-session-tree.ts @@ -231,7 +231,6 @@ export class PeripheralTreeForSession extends PeripheralBaseNode { public getCDTTreeItem(): MaybePromise { return CDTTreeItem.create({ id: this.getId(), - key: this.getId(), label: this.getTitle(), path: [], }); diff --git a/src/plugin/peripheral/tree/provider/peripheral-cdt-tree-data-provider.ts b/src/plugin/peripheral/tree/provider/peripheral-cdt-tree-data-provider.ts index 9297ed5..f037ecd 100644 --- a/src/plugin/peripheral/tree/provider/peripheral-cdt-tree-data-provider.ts +++ b/src/plugin/peripheral/tree/provider/peripheral-cdt-tree-data-provider.ts @@ -90,7 +90,7 @@ export class PeripheralCDTTreeDataProvider implements CDTTreeDataProvider