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

View File

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

View File

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

View File

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

View File

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