chore: introduce tree control (#21505)

This commit is contained in:
Pavel Feldman 2023-03-08 17:33:27 -08:00 committed by GitHub
parent a2490a8fc8
commit adc895d31f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 303 additions and 201 deletions

View File

@ -14,7 +14,7 @@
limitations under the License.
*/
import type { ActionTraceEvent } from '@trace/trace';
import { ActionTraceEvent } from '@trace/trace';
import { msToString } from '@web/uiUtils';
import { ListView } from '@web/components/listView';
import * as React from 'react';
@ -32,6 +32,8 @@ export interface ActionListProps {
revealConsole: () => void,
}
const ActionListView = ListView<ActionTraceEvent>;
export const ActionList: React.FC<ActionListProps> = ({
actions = [],
selectedAction,
@ -40,16 +42,16 @@ export const ActionList: React.FC<ActionListProps> = ({
onHighlighted = () => {},
revealConsole = () => {},
}) => {
return <ListView
return <ActionListView
items={actions}
id={action => action.callId}
selectedItem={selectedAction}
onSelected={(action: ActionTraceEvent) => onSelected(action)}
onHighlighted={(action: ActionTraceEvent) => onHighlighted(action)}
itemKey={(action: ActionTraceEvent) => action.callId}
itemType={(action: ActionTraceEvent) => action.error?.message ? 'error' : undefined}
itemRender={(action: ActionTraceEvent) => renderAction(action, sdkLanguage, revealConsole)}
onSelected={onSelected}
onHighlighted={onHighlighted}
isError={action => !!action.error?.message}
render={action => renderAction(action, sdkLanguage, revealConsole)}
noItemsMessage='No actions'
></ListView>;
/>;
};
const renderAction = (

View File

@ -18,6 +18,10 @@ import * as React from 'react';
import './stackTrace.css';
import type { ActionTraceEvent } from '@trace/trace';
import { ListView } from '@web/components/listView';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { StackFrame } from '@protocol/channels';
const StackFrameListView = ListView<StackFrame>;
export const StackTraceView: React.FunctionComponent<{
action: ActionTraceEvent | undefined,
@ -25,11 +29,11 @@ export const StackTraceView: React.FunctionComponent<{
setSelectedFrame: (index: number) => void
}> = ({ action, setSelectedFrame, selectedFrame }) => {
const frames = action?.stack || [];
return <ListView
return <StackFrameListView
dataTestId='stack-trace'
items={frames}
selectedItem={frames[selectedFrame]}
itemRender={frame => {
render={frame => {
const pathSep = frame.file[1] === ':' ? '\\' : '/';
return <>
<span className='stack-trace-frame-function'>

View File

@ -18,7 +18,8 @@ import '@web/third_party/vscode/codicon.css';
import { Workbench } from './workbench';
import '@web/common.css';
import React from 'react';
import { ListView } from '@web/components/listView';
import { TreeView } from '@web/components/treeView';
import type { TreeState } from '@web/components/treeView';
import { TeleReporterReceiver } from '../../../playwright-test/src/isomorphic/teleReceiver';
import type { FullConfig, Suite, TestCase, TestResult, TestStep, Location } from '../../../playwright-test/types/testReporter';
import { SplitView } from '@web/components/splitView';
@ -50,7 +51,7 @@ export const WatchModeView: React.FC<{}> = ({
const [rootSuite, setRootSuite] = React.useState<{ value: Suite | undefined }>({ value: undefined });
const [isRunningTest, setIsRunningTest] = React.useState<boolean>(false);
const [progress, setProgress] = React.useState<Progress>({ total: 0, passed: 0, failed: 0 });
const [selectedTestItem, setSelectedTestItem] = React.useState<TestItem | undefined>(undefined);
const [selectedTest, setSelectedTest] = React.useState<TestCase | undefined>(undefined);
const [settingsVisible, setSettingsVisible] = React.useState<boolean>(false);
const [isWatchingFiles, setIsWatchingFiles] = React.useState<boolean>(true);
@ -84,7 +85,7 @@ export const WatchModeView: React.FC<{}> = ({
return <div className='vbox'>
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
<TraceView testItem={selectedTestItem}></TraceView>
<TraceView test={selectedTest}></TraceView>
<div className='vbox watch-mode-sidebar'>
<Toolbar>
<div className='section-title' style={{ cursor: 'pointer' }} onClick={() => setSettingsVisible(false)}>Tests</div>
@ -101,7 +102,7 @@ export const WatchModeView: React.FC<{}> = ({
isRunningTest={isRunningTest}
isWatchingFiles={isWatchingFiles}
runTests={runTests}
onTestItemSelected={setSelectedTestItem}
onTestSelected={setSelectedTest}
isVisible={!settingsVisible} />
{settingsVisible && <SettingsView projects={projects} setProjects={setProjects} onClose={() => setSettingsVisible(false)}></SettingsView>}
</div>
@ -112,6 +113,8 @@ export const WatchModeView: React.FC<{}> = ({
</div>;
};
const TreeListView = TreeView<TreeItem>;
export const TestList: React.FC<{
projects: Map<string, boolean>,
rootSuite: { value: Suite | undefined },
@ -119,11 +122,11 @@ export const TestList: React.FC<{
isRunningTest: boolean,
isWatchingFiles: boolean,
isVisible: boolean
onTestItemSelected: (test: TestItem | undefined) => void,
}> = ({ projects, rootSuite, runTests, isRunningTest, isWatchingFiles, isVisible, onTestItemSelected }) => {
onTestSelected: (test: TestCase | undefined) => void,
}> = ({ projects, rootSuite, runTests, isRunningTest, isWatchingFiles, isVisible, onTestSelected }) => {
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
const [filterText, setFilterText] = React.useState<string>('');
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [expandedItems, setExpandedItems] = React.useState<Map<string, boolean>>(new Map());
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
@ -131,10 +134,9 @@ export const TestList: React.FC<{
refreshRootSuite(true);
}, []);
const { filteredItems, treeItemMap, visibleTestIds } = React.useMemo(() => {
const treeItems = createTree(rootSuite.value, projects);
const filteredItems = filterTree(treeItems, filterText);
const { rootItem, treeItemMap, visibleTestIds } = React.useMemo(() => {
const rootItem = createTree(rootSuite.value, projects);
filterTree(rootItem, filterText);
const treeItemMap = new Map<string, TreeItem>();
const visibleTestIds = new Set<string>();
const visit = (treeItem: TreeItem) => {
@ -143,35 +145,30 @@ export const TestList: React.FC<{
treeItem.children?.forEach(visit);
treeItemMap.set(treeItem.id, treeItem);
};
filteredItems.forEach(visit);
return { treeItemMap, visibleTestIds, filteredItems };
visit(rootItem);
hideOnlyTests(rootItem);
return { rootItem, treeItemMap, visibleTestIds };
}, [filterText, rootSuite, projects]);
runVisibleTests = () => runTests([...visibleTestIds]);
const { listItems } = React.useMemo(() => {
const listItems = flattenTree(filteredItems, expandedItems, !!filterText.trim());
return { listItems };
}, [filteredItems, filterText, expandedItems]);
const { selectedTreeItem, selectedTestItem } = React.useMemo(() => {
const { selectedTreeItem } = React.useMemo(() => {
const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined;
let selectedTestItem: TestItem | undefined;
let selectedTest: TestCase | undefined;
if (selectedTreeItem?.kind === 'test')
selectedTestItem = selectedTreeItem;
else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.children?.length === 1)
selectedTestItem = selectedTreeItem.children[0]! as TestItem;
return { selectedTreeItem, selectedTestItem };
}, [selectedTreeItemId, treeItemMap]);
selectedTest = selectedTreeItem.test;
else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1)
selectedTest = selectedTreeItem.tests[0];
onTestSelected(selectedTest);
return { selectedTreeItem };
}, [onTestSelected, selectedTreeItemId, treeItemMap]);
React.useEffect(() => {
sendMessageNoReply('watch', { fileName: isWatchingFiles ? fileName(selectedTreeItem) : undefined });
}, [selectedTreeItem, isWatchingFiles]);
onTestItemSelected(selectedTestItem);
const runTreeItem = (treeItem: TreeItem) => {
expandedItems.set(treeItem.id, true);
// expandedItems.set(treeItem.id, true);
setSelectedTreeItemId(treeItem.id);
runTests(collectTestIds(treeItem));
};
@ -194,19 +191,35 @@ export const TestList: React.FC<{
runVisibleTests();
}}></input>
</Toolbar>
<ListView
items={listItems}
itemKey={(treeItem: TreeItem) => treeItem.id }
itemRender={(treeItem: TreeItem) => {
<TreeListView
treeState={treeState}
setTreeState={setTreeState}
rootItem={rootItem}
render={treeItem => {
return <div className='hbox watch-mode-list-item'>
<div className='watch-mode-list-item-title'>{treeItem.title}</div>
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={isRunningTest}></ToolbarButton>
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton>
</div>;
}}
itemIcon={(treeItem: TreeItem) => {
if (treeItem.kind === 'case' && treeItem.children?.length === 1)
treeItem = treeItem.children[0];
icon={treeItem => {
if (treeItem.kind === 'case') {
let allOk = true;
let hasFailed = false;
let hasRunning = false;
for (const test of treeItem.tests) {
allOk = allOk && test.outcome() === 'expected';
hasFailed = hasFailed || (!!test.results.length && test.outcome() !== 'expected');
hasRunning = hasRunning || test.results.some(r => r.duration === -1);
}
if (hasRunning)
return 'codicon-loading';
if (allOk)
return 'codicon-check';
if (hasFailed)
return 'codicon-error';
}
if (treeItem.kind === 'test') {
const ok = treeItem.test.outcome() === 'expected';
const failed = treeItem.test.results.length && treeItem.test.outcome() !== 'expected';
@ -217,39 +230,14 @@ export const TestList: React.FC<{
return 'codicon-check';
if (failed)
return 'codicon-error';
} else {
return treeItem.expanded ? 'codicon-chevron-down' : 'codicon-chevron-right';
}
return 'codicon-circle-outline';
}}
itemIndent={(treeItem: TreeItem) => treeItem.kind === 'file' ? 0 : treeItem.kind === 'case' ? 1 : 2}
selectedItem={selectedTreeItem}
onAccepted={runTreeItem}
onLeftArrow={(treeItem: TreeItem) => {
if (treeItem.children && treeItem.expanded) {
expandedItems.set(treeItem.id, false);
setExpandedItems(new Map(expandedItems));
} else {
setSelectedTreeItemId(treeItem.parent?.id);
}
}}
onRightArrow={(treeItem: TreeItem) => {
if (treeItem.children) {
expandedItems.set(treeItem.id, true);
setExpandedItems(new Map(expandedItems));
}
}}
onSelected={(treeItem: TreeItem) => {
onSelected={treeItem => {
setSelectedTreeItemId(treeItem.id);
}}
onIconClicked={(treeItem: TreeItem) => {
if (treeItem.kind === 'test')
return;
if (treeItem.expanded)
expandedItems.set(treeItem.id, false);
else
expandedItems.set(treeItem.id, true);
setExpandedItems(new Map(expandedItems));
}}
noItemsMessage='No tests' />
</div>;
};
@ -287,20 +275,20 @@ export const SettingsView: React.FC<{
};
export const TraceView: React.FC<{
testItem: TestItem | undefined,
}> = ({ testItem }) => {
test: TestCase | undefined,
}> = ({ test }) => {
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
const [stepsProgress, setStepsProgress] = React.useState(0);
updateStepsProgress = () => setStepsProgress(stepsProgress + 1);
React.useEffect(() => {
(async () => {
if (!testItem) {
if (!test) {
setModel(undefined);
return;
}
const result = testItem.test?.results?.[0];
const result = test.results?.[0];
if (result) {
const attachment = result.attachments.find(a => a.name === 'trace');
if (attachment && attachment.path)
@ -311,7 +299,7 @@ export const TraceView: React.FC<{
setModel(undefined);
}
})();
}, [testItem, stepsProgress]);
}, [test, stepsProgress]);
const xterm = <XtermWrapper source={xtermDataSource}></XtermWrapper>;
return <Workbench model={model} output={xterm} rightToolbar={[
@ -430,9 +418,12 @@ const collectTestIds = (treeItem?: TreeItem): string[] => {
return [];
const testIds: string[] = [];
const visit = (treeItem: TreeItem) => {
if (treeItem.kind === 'test')
if (treeItem.kind === 'case')
testIds.push(...treeItem.tests.map(t => t.id));
else if (treeItem.kind === 'test')
testIds.push(treeItem.id);
treeItem.children?.forEach(visit);
else
treeItem.children?.forEach(visit);
};
visit(treeItem);
return testIds;
@ -445,22 +436,28 @@ type Progress = {
};
type TreeItemBase = {
kind: 'file' | 'case' | 'test',
kind: 'root' | 'file' | 'case' | 'test',
id: string;
title: string;
parent: TreeItem | null;
children?: TreeItem[];
children: TreeItem[];
expanded?: boolean;
};
type RootItem = TreeItemBase & {
kind: 'root',
children: FileItem[];
};
type FileItem = TreeItemBase & {
kind: 'file',
file: string;
children?: TestCaseItem[];
children: TestCaseItem[];
};
type TestCaseItem = TreeItemBase & {
kind: 'case',
tests: TestCase[];
location: Location,
};
@ -469,9 +466,16 @@ type TestItem = TreeItemBase & {
test: TestCase;
};
type TreeItem = FileItem | TestCaseItem | TestItem;
type TreeItem = RootItem | FileItem | TestCaseItem | TestItem;
function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean>): FileItem[] {
function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean>): RootItem {
const rootItem: RootItem = {
kind: 'root',
id: 'root',
title: '',
parent: null,
children: [],
};
const fileItems = new Map<string, FileItem>();
for (const projectSuite of rootSuite?.suites || []) {
if (!projects.get(projectSuite.title))
@ -491,11 +495,12 @@ function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean>
expanded: false,
};
fileItems.set(fileSuite.location!.file, fileItem);
rootItem.children.push(fileItem);
}
for (const test of fileSuite.allTests()) {
const title = test.titlePath().slice(3).join(' ');
let testCaseItem = fileItem.children!.find(t => t.title === title);
let testCaseItem = fileItem.children.find(t => t.title === title) as TestCaseItem;
if (!testCaseItem) {
testCaseItem = {
kind: 'case',
@ -503,62 +508,56 @@ function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean>
title,
parent: fileItem,
children: [],
tests: [],
expanded: false,
location: test.location,
};
fileItem.children!.push(testCaseItem);
fileItem.children.push(testCaseItem);
}
testCaseItem.children!.push({
testCaseItem.tests.push(test);
testCaseItem.children.push({
kind: 'test',
id: test.id,
title: projectSuite.title,
parent: testCaseItem,
test,
children: [],
});
}
(fileItem.children as TestCaseItem[]).sort((a, b) => a.location.line - b.location.line);
}
}
return [...fileItems.values()];
return rootItem;
}
function filterTree(fileItems: FileItem[], filterText: string): FileItem[] {
function filterTree(rootItem: RootItem, filterText: string) {
const trimmedFilterText = filterText.trim();
const filterTokens = trimmedFilterText.toLowerCase().split(' ');
const result: FileItem[] = [];
for (const fileItem of fileItems) {
for (const fileItem of rootItem.children) {
if (trimmedFilterText) {
const filteredCases: TestCaseItem[] = [];
for (const testCaseItem of fileItem.children!) {
for (const testCaseItem of fileItem.children) {
const fullTitle = (fileItem.title + ' ' + testCaseItem.title).toLowerCase();
if (filterTokens.every(token => fullTitle.includes(token)))
filteredCases.push(testCaseItem);
}
fileItem.children = filteredCases;
}
if (fileItem.children!.length)
if (fileItem.children.length)
result.push(fileItem);
}
return result;
rootItem.children = result;
}
function flattenTree(fileItems: FileItem[], expandedItems: Map<string, boolean | undefined>, hasFilter: boolean): TreeItem[] {
const result: TreeItem[] = [];
for (const fileItem of fileItems) {
result.push(fileItem);
const expandState = expandedItems.get(fileItem.id);
const autoExpandMatches = result.length < 100 && (hasFilter && expandState !== false);
fileItem.expanded = expandState || autoExpandMatches || fileItems.length < 10;
if (fileItem.expanded) {
for (const testCaseItem of fileItem.children!) {
result.push(testCaseItem);
testCaseItem.expanded = !!expandedItems.get(testCaseItem.id);
if (testCaseItem.expanded && testCaseItem.children!.length > 1)
result.push(...testCaseItem.children!);
}
}
}
return result;
function hideOnlyTests(rootItem: RootItem) {
const visit = (treeItem: TreeItem) => {
if (treeItem.kind === 'case' && treeItem.children.length === 1)
treeItem.children = [];
else
treeItem.children.forEach(visit);
};
visit(rootItem);
}
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {

View File

@ -109,6 +109,10 @@ svg {
color: var(--red);
}
.codicon-circle-outline {
color: var(--vscode-disabledForeground);
}
input[type=text], input[type=search] {
color: var(--vscode-input-foreground);
background-color: var(--vscode-input-background);

View File

@ -17,31 +17,31 @@
import * as React from 'react';
import './listView.css';
export type ListViewProps = {
items: any[],
itemRender: (item: any) => React.ReactNode,
itemKey?: (item: any) => string,
itemIcon?: (item: any) => string | undefined,
itemIndent?: (item: any) => number | undefined,
itemType?: (item: any) => 'error' | undefined,
selectedItem?: any,
onAccepted?: (item: any) => void,
onSelected?: (item: any) => void,
onLeftArrow?: (item: any) => void,
onRightArrow?: (item: any) => void,
onHighlighted?: (item: any | undefined) => void,
onIconClicked?: (item: any) => void,
export type ListViewProps<T> = {
items: T[],
id?: (item: T) => string,
render: (item: T) => React.ReactNode,
icon?: (item: T) => string | undefined,
indent?: (item: T) => number | undefined,
isError?: (item: T) => boolean,
selectedItem?: T,
onAccepted?: (item: T) => void,
onSelected?: (item: T) => void,
onLeftArrow?: (item: T) => void,
onRightArrow?: (item: T) => void,
onHighlighted?: (item: T | undefined) => void,
onIconClicked?: (item: T) => void,
noItemsMessage?: string,
dataTestId?: string,
};
export const ListView: React.FC<ListViewProps> = ({
export function ListView<T>({
items = [],
itemKey,
itemRender,
itemIcon,
itemType,
itemIndent,
id,
render,
icon,
isError,
indent,
selectedItem,
onAccepted,
onSelected,
@ -51,17 +51,21 @@ export const ListView: React.FC<ListViewProps> = ({
onIconClicked,
noItemsMessage,
dataTestId,
}) => {
}: ListViewProps<T>) {
const itemListRef = React.createRef<HTMLDivElement>();
const [highlightedItem, setHighlightedItem] = React.useState<any>();
React.useEffect(() => {
onHighlighted?.(highlightedItem);
}, [onHighlighted, highlightedItem]);
return <div className='list-view vbox' data-testid={dataTestId}>
<div
className='list-view-content'
tabIndex={0}
onDoubleClick={() => onAccepted?.(selectedItem)}
onDoubleClick={() => selectedItem && onAccepted?.(selectedItem)}
onKeyDown={event => {
if (event.key === 'Enter') {
if (selectedItem && event.key === 'Enter') {
onAccepted?.(selectedItem);
return;
}
@ -71,11 +75,11 @@ export const ListView: React.FC<ListViewProps> = ({
event.stopPropagation();
event.preventDefault();
if (event.key === 'ArrowLeft') {
if (selectedItem && event.key === 'ArrowLeft') {
onLeftArrow?.(selectedItem);
return;
}
if (event.key === 'ArrowRight') {
if (selectedItem && event.key === 'ArrowRight') {
onRightArrow?.(selectedItem);
return;
}
@ -96,81 +100,40 @@ export const ListView: React.FC<ListViewProps> = ({
}
const element = itemListRef.current?.children.item(newIndex);
scrollIntoViewIfNeeded(element);
scrollIntoViewIfNeeded(element || undefined);
onHighlighted?.(undefined);
onSelected?.(items[newIndex]);
}}
ref={itemListRef}
>
{noItemsMessage && items.length === 0 && <div className='list-view-empty'>{noItemsMessage}</div>}
{items.map((item, index) => <ListItemView
key={itemKey ? itemKey(item) : String(index)}
hasIcons={!!itemIcon}
icon={itemIcon?.(item)}
type={itemType?.(item)}
indent={itemIndent?.(item)}
isHighlighted={item === highlightedItem}
isSelected={item === selectedItem}
onSelected={() => onSelected?.(item)}
onMouseEnter={() => {
setHighlightedItem(item);
onHighlighted?.(item);
}}
onMouseLeave={() => {
setHighlightedItem(undefined);
onHighlighted?.(undefined);
}}
onIconClicked={() => onIconClicked?.(item)}
>
{itemRender(item)}
</ListItemView>)}
{items.map((item, index) => {
const selectedSuffix = selectedItem === item ? ' selected' : '';
const highlightedSuffix = highlightedItem === item ? ' highlighted' : '';
const errorSuffix = isError?.(item) ? ' error' : '';
const indentation = indent?.(item) || 0;
const rendered = render(item);
return <div
key={id?.(item) || index}
className={'list-view-entry' + selectedSuffix + highlightedSuffix + errorSuffix}
onClick={() => onSelected?.(item)}
onMouseEnter={() => setHighlightedItem(item)}
onMouseLeave={() => setHighlightedItem(undefined)}
>
{indentation ? <div style={{ minWidth: indentation * 16 }}></div> : undefined}
{icon && <div className={'codicon ' + (icon(item) || 'blank')} style={{ minWidth: 16, marginRight: 4 }} onClick={e => {
e.stopPropagation();
e.preventDefault();
onIconClicked?.(item);
}}></div>}
{typeof rendered === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{rendered}</div> : rendered}
</div>;
})}
</div>
</div>;
};
}
const ListItemView: React.FC<{
key: string,
hasIcons: boolean,
icon: string | undefined,
type: 'error' | undefined,
indent: number | undefined,
isHighlighted: boolean,
isSelected: boolean,
onSelected: () => void,
onMouseEnter: () => void,
onMouseLeave: () => void,
onIconClicked: () => void,
children: React.ReactNode | React.ReactNode[],
}> = ({ key, hasIcons, icon, type, indent, onSelected, onMouseEnter, onMouseLeave, onIconClicked, isHighlighted, isSelected, children }) => {
const selectedSuffix = isSelected ? ' selected' : '';
const highlightedSuffix = isHighlighted ? ' highlighted' : '';
const errorSuffix = type === 'error' ? ' error' : '';
const divRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (divRef.current && isSelected)
scrollIntoViewIfNeeded(divRef.current);
}, [isSelected]);
return <div
key={key}
className={'list-view-entry' + selectedSuffix + highlightedSuffix + errorSuffix}
onClick={onSelected}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
ref={divRef}
>
{indent ? <div style={{ minWidth: indent * 16 }}></div> : undefined}
{hasIcons && <div className={'codicon ' + (icon || 'blank')} style={{ minWidth: 16, marginRight: 4 }} onClick={e => {
e.stopPropagation();
e.preventDefault();
onIconClicked();
}}></div>}
{typeof children === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{children}</div> : children}
</div>;
};
function scrollIntoViewIfNeeded(element?: Element | null) {
function scrollIntoViewIfNeeded(element: Element | undefined) {
if (!element)
return;
if ((element as any)?.scrollIntoViewIfNeeded)

View File

@ -0,0 +1,130 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from 'react';
import { ListView } from './listView';
export type TreeItem = {
id: string,
children: TreeItem[],
};
export type TreeState = {
expandedItems: Map<string, boolean>;
};
export type TreeViewProps<T> = {
rootItem: T,
render: (item: T) => React.ReactNode,
icon?: (item: T) => string | undefined,
isError?: (item: T) => boolean,
selectedItem?: T,
onAccepted?: (item: T) => void,
onSelected?: (item: T) => void,
onHighlighted?: (item: T | undefined) => void,
noItemsMessage?: string,
dataTestId?: string,
treeState: TreeState,
setTreeState: (treeState: TreeState) => void,
};
const TreeListView = ListView<TreeItem>;
export function TreeView<T extends TreeItem>({
rootItem,
render,
icon,
isError,
selectedItem,
onAccepted,
onSelected,
onHighlighted,
treeState,
setTreeState,
noItemsMessage,
}: TreeViewProps<T>) {
const treeItems = React.useMemo(() => {
return flattenTree<T>(rootItem, treeState.expandedItems);
}, [rootItem, treeState]);
return <TreeListView
items={[...treeItems.keys()]}
id={item => item.id}
render={item => {
const rendered = render(item as T);
return <>
{icon && <div className={'codicon ' + (icon(item as T) || 'blank')} style={{ minWidth: 16, marginRight: 4 }}></div>}
{typeof rendered === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{rendered}</div> : rendered}
</>;
}}
icon={item => {
const expanded = treeItems.get(item as T)!.expanded;
if (typeof expanded === 'boolean')
return expanded ? 'codicon-chevron-down' : 'codicon-chevron-right';
}}
isError={item => isError?.(item as T) || false}
indent={item => treeItems.get(item as T)!.depth}
selectedItem={selectedItem}
onAccepted={item => onAccepted?.(item as T)}
onSelected={item => onSelected?.(item as T)}
onHighlighted={item => onHighlighted?.(item as T)}
onLeftArrow={item => {
const { expanded, parent } = treeItems.get(item as T)!;
if (expanded) {
treeState.expandedItems.set(item.id, false);
setTreeState({ ...treeState });
} else if (parent) {
onSelected?.(parent as T);
}
}}
onRightArrow={item => {
if (item.children.length) {
treeState.expandedItems.set(item.id, true);
setTreeState({ ...treeState });
}
}}
onIconClicked={item => {
const { expanded } = treeItems.get(item as T)!;
if (expanded)
treeState.expandedItems.set(item.id, false);
else
treeState.expandedItems.set(item.id, true);
setTreeState({ ...treeState });
}}
noItemsMessage={noItemsMessage} />;
}
type TreeItemData = {
depth: number,
expanded: boolean | undefined,
parent: TreeItem | null,
};
function flattenTree<T extends TreeItem>(rootItem: T, expandedItems: Map<string, boolean | undefined>): Map<T, TreeItemData> {
const result = new Map<T, TreeItemData>();
const appendChildren = (parent: T, depth: number) => {
for (const item of parent.children as T[]) {
const expandState = expandedItems.get(item.id);
const autoExpandMatches = depth === 0 && result.size < 25 && expandState !== false;
const expanded = item.children.length ? expandState || autoExpandMatches : undefined;
result.set(item, { depth, expanded, parent: rootItem === parent ? null : parent });
if (expanded)
appendChildren(item, depth + 1);
}
};
appendChildren(rootItem, 0);
return result;
}