Plugin folders re-structuring
Summary: Here I'm changing plugin repository structure to allow re-using of shared packages between both public and fb-internal plugins, and to ensure that public plugins has their own yarn.lock as this will be required to implement reproducible jobs checking plugin compatibility with released flipper versions. Please note that there are a lot of moved files in this diff, make sure to click "Expand all" to see all that actually changed (there are not much of them actually). New proposed structure for plugin packages: ``` - root - node_modules - modules included into Flipper: flipper, flipper-plugin, react, antd, emotion -- plugins --- node_modules - modules used by both public and fb-internal plugins (shared libs will be linked here, see D27034936) --- public ---- node_modules - modules used by public plugins ---- pluginA ----- node_modules - modules used by plugin A exclusively ---- pluginB ----- node_modules - modules used by plugin B exclusively --- fb ---- node_modules - modules used by fb-internal plugins ---- pluginC ----- node_modules - modules used by plugin C exclusively ---- pluginD ----- node_modules - modules used by plugin D exclusively ``` I've moved all public plugins under dir "plugins/public" and excluded them from root yarn workspaces. Instead, they will have their own yarn workspaces config and yarn.lock and they will use flipper modules as peer dependencies. Reviewed By: mweststrate Differential Revision: D27034108 fbshipit-source-id: c2310e3c5bfe7526033f51b46c0ae40199fd7586
This commit is contained in:
committed by
Facebook GitHub Bot
parent
32bf4c32c2
commit
b3274a8450
474
desktop/plugins/public/layout/Inspector.tsx
Normal file
474
desktop/plugins/public/layout/Inspector.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
ElementID,
|
||||
Element,
|
||||
PluginClient,
|
||||
ElementsInspector,
|
||||
ElementSearchResultSet,
|
||||
FlexColumn,
|
||||
styled,
|
||||
} from 'flipper';
|
||||
import {debounce} from 'lodash';
|
||||
import {Component} from 'react';
|
||||
import {PersistedState, ElementMap} from './';
|
||||
import React from 'react';
|
||||
import MultipleSelectorSection from './MultipleSelectionSection';
|
||||
|
||||
const ElementsInspectorContainer = styled(FlexColumn)({
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
type GetNodesOptions = {
|
||||
force?: boolean;
|
||||
ax?: boolean;
|
||||
forAccessibilityEvent?: boolean;
|
||||
};
|
||||
|
||||
export type ElementSelectorNode = {[id: string]: ElementSelectorNode};
|
||||
export type ElementSelectorData = {
|
||||
leaves: Array<ElementID>;
|
||||
tree: ElementSelectorNode;
|
||||
elements: ElementMap;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
ax?: boolean;
|
||||
client: PluginClient;
|
||||
showsSidebar: boolean;
|
||||
inAlignmentMode?: boolean;
|
||||
selectedElement: ElementID | null | undefined;
|
||||
selectedAXElement: ElementID | null | undefined;
|
||||
onSelect: (ids: ElementID | null | undefined) => void;
|
||||
setPersistedState: (state: Partial<PersistedState>) => void;
|
||||
persistedState: PersistedState;
|
||||
searchResults: ElementSearchResultSet | null;
|
||||
};
|
||||
|
||||
type State = {
|
||||
elementSelector: ElementSelectorData | null;
|
||||
axElementSelector: ElementSelectorData | null;
|
||||
};
|
||||
|
||||
export default class Inspector extends Component<Props, State> {
|
||||
state: State = {elementSelector: null, axElementSelector: null};
|
||||
|
||||
call() {
|
||||
return {
|
||||
GET_ROOT: this.props.ax ? 'getAXRoot' : 'getRoot',
|
||||
INVALIDATE: this.props.ax ? 'invalidateAX' : 'invalidate',
|
||||
GET_NODES: this.props.ax ? 'getAXNodes' : 'getNodes',
|
||||
SET_HIGHLIGHTED: 'setHighlighted',
|
||||
SELECT: this.props.ax ? 'selectAX' : 'select',
|
||||
INVALIDATE_WITH_DATA: this.props.ax
|
||||
? 'invalidateWithDataAX'
|
||||
: 'invalidateWithData',
|
||||
};
|
||||
}
|
||||
|
||||
selected = () => {
|
||||
return this.props.ax
|
||||
? this.props.selectedAXElement
|
||||
: this.props.selectedElement;
|
||||
};
|
||||
|
||||
root = () => {
|
||||
return this.props.ax
|
||||
? this.props.persistedState.rootAXElement
|
||||
: this.props.persistedState.rootElement;
|
||||
};
|
||||
|
||||
elements = () => {
|
||||
return this.props.ax
|
||||
? this.props.persistedState.AXelements
|
||||
: this.props.persistedState.elements;
|
||||
};
|
||||
|
||||
focused = () => {
|
||||
if (!this.props.ax) {
|
||||
return null;
|
||||
}
|
||||
const elements: Array<Element> = Object.values(
|
||||
this.props.persistedState.AXelements,
|
||||
);
|
||||
const focusedElement = elements.find((i) =>
|
||||
Boolean(
|
||||
i.data.Accessibility && i.data.Accessibility['accessibility-focused'],
|
||||
),
|
||||
);
|
||||
return focusedElement ? focusedElement.id : null;
|
||||
};
|
||||
|
||||
getAXContextMenuExtensions = () =>
|
||||
this.props.ax
|
||||
? [
|
||||
{
|
||||
label: 'Focus',
|
||||
click: (id: ElementID) => {
|
||||
if (this.props.client.isConnected) {
|
||||
this.props.client.call('onRequestAXFocus', {id});
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.client.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.props.client.call(this.call().GET_ROOT).then((root: Element) => {
|
||||
this.props.setPersistedState({
|
||||
[this.props.ax ? 'rootAXElement' : 'rootElement']: root.id,
|
||||
});
|
||||
this.updateElement(root.id, {...root, expanded: true});
|
||||
this.performInitialExpand(root);
|
||||
});
|
||||
|
||||
this.props.client.subscribe(
|
||||
this.call().INVALIDATE,
|
||||
({
|
||||
nodes,
|
||||
}: {
|
||||
nodes: Array<{id: ElementID; children: Array<ElementID>}>;
|
||||
}) => {
|
||||
const ids = nodes
|
||||
.map((n) => [n.id, ...(n.children || [])])
|
||||
.reduce((acc, cv) => acc.concat(cv), []);
|
||||
this.invalidate(ids);
|
||||
},
|
||||
);
|
||||
|
||||
this.props.client.subscribe(
|
||||
this.call().INVALIDATE_WITH_DATA,
|
||||
(obj: {nodes: Array<Element>}) => {
|
||||
const {nodes} = obj;
|
||||
this.invalidateWithData(nodes);
|
||||
},
|
||||
);
|
||||
|
||||
this.props.client.subscribe(
|
||||
this.call().SELECT,
|
||||
async ({
|
||||
path,
|
||||
tree,
|
||||
}: {
|
||||
path?: Array<ElementID>;
|
||||
tree?: ElementSelectorNode;
|
||||
}) => {
|
||||
if (path) {
|
||||
this.getAndExpandPath(path);
|
||||
}
|
||||
if (tree) {
|
||||
const leaves = this.getElementLeaves(tree);
|
||||
const elementArray = await this.getNodes(leaves, {});
|
||||
const elements = leaves.reduce(
|
||||
(acc, cur, idx) => ({...acc, [cur]: elementArray[idx]}),
|
||||
{},
|
||||
);
|
||||
if (this.props.ax) {
|
||||
this.setState({axElementSelector: {tree, leaves, elements}});
|
||||
} else {
|
||||
this.setState({elementSelector: {tree, leaves, elements}});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (this.props.ax) {
|
||||
this.props.client.subscribe('axFocusEvent', () => {
|
||||
// update all nodes, to find new focused node
|
||||
this.getNodes(Object.keys(this.props.persistedState.AXelements), {
|
||||
force: true,
|
||||
ax: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
const {ax, selectedElement, selectedAXElement} = this.props;
|
||||
|
||||
if (
|
||||
ax &&
|
||||
selectedElement &&
|
||||
selectedElement !== prevProps.selectedElement
|
||||
) {
|
||||
// selected element in non-AX tree changed, find linked element in AX tree
|
||||
const newlySelectedElem = this.props.persistedState.elements[
|
||||
selectedElement
|
||||
];
|
||||
if (newlySelectedElem) {
|
||||
this.props.onSelect(
|
||||
newlySelectedElem.extraInfo
|
||||
? newlySelectedElem.extraInfo.linkedNode
|
||||
: null,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
!ax &&
|
||||
selectedAXElement &&
|
||||
selectedAXElement !== prevProps.selectedAXElement
|
||||
) {
|
||||
// selected element in AX tree changed, find linked element in non-AX tree
|
||||
const newlySelectedAXElem = this.props.persistedState.AXelements[
|
||||
selectedAXElement
|
||||
];
|
||||
if (newlySelectedAXElem) {
|
||||
this.props.onSelect(
|
||||
newlySelectedAXElem.extraInfo
|
||||
? newlySelectedAXElem.extraInfo.linkedNode
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidateWithData(elements: Array<Element>): void {
|
||||
if (elements.length === 0) {
|
||||
return;
|
||||
}
|
||||
const updatedElements: ElementMap = elements.reduce(
|
||||
(acc: ElementMap, element: Element) => {
|
||||
acc[element.id] = {
|
||||
...element,
|
||||
expanded: this.elements()[element.id]
|
||||
? this.elements()[element.id].expanded
|
||||
: false,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
this.props.setPersistedState({
|
||||
[this.props.ax ? 'AXelements' : 'elements']: {
|
||||
...this.elements(),
|
||||
...updatedElements,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async invalidate(ids: Array<ElementID>): Promise<Array<Element>> {
|
||||
if (ids.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const elements = await this.getNodes(ids, {});
|
||||
const children = elements
|
||||
.filter(
|
||||
(element: Element) =>
|
||||
this.elements()[element.id] && this.elements()[element.id].expanded,
|
||||
)
|
||||
.map((element: Element) => element.children)
|
||||
.reduce((acc, val) => acc.concat(val), []);
|
||||
return this.invalidate(children);
|
||||
}
|
||||
|
||||
updateElement(id: ElementID, data: Object) {
|
||||
this.props.setPersistedState({
|
||||
[this.props.ax ? 'AXelements' : 'elements']: {
|
||||
...this.elements(),
|
||||
[id]: {
|
||||
...this.elements()[id],
|
||||
...data,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// When opening the inspector for the first time, expand all elements that
|
||||
// contain only 1 child recursively.
|
||||
async performInitialExpand(element: Element): Promise<void> {
|
||||
if (!element.children.length) {
|
||||
// element has no children so we're as deep as we can be
|
||||
return;
|
||||
}
|
||||
return this.getChildren(element.id, {}).then(() => {
|
||||
if (element.children.length >= 2) {
|
||||
// element has two or more children so we can stop expanding
|
||||
return;
|
||||
}
|
||||
return this.performInitialExpand(this.elements()[element.children[0]]);
|
||||
});
|
||||
}
|
||||
|
||||
async getChildren(
|
||||
id: ElementID,
|
||||
options: GetNodesOptions,
|
||||
): Promise<Array<Element>> {
|
||||
if (!this.elements()[id]) {
|
||||
await this.getNodes([id], options);
|
||||
}
|
||||
this.updateElement(id, {expanded: true});
|
||||
return this.getNodes(this.elements()[id].children, options);
|
||||
}
|
||||
|
||||
async getNodes(
|
||||
ids: Array<ElementID> = [],
|
||||
options: GetNodesOptions,
|
||||
): Promise<Array<Element>> {
|
||||
if (ids.length > 0 && this.props.client.isConnected) {
|
||||
const {forAccessibilityEvent} = options;
|
||||
const {
|
||||
elements,
|
||||
}: {elements: Array<Element>} = await this.props.client.call(
|
||||
this.call().GET_NODES,
|
||||
{
|
||||
ids,
|
||||
forAccessibilityEvent,
|
||||
selected: false,
|
||||
},
|
||||
);
|
||||
elements.forEach((e) => this.updateElement(e.id, e));
|
||||
return elements;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getAndExpandPath(path: Array<ElementID>) {
|
||||
await Promise.all(path.map((id) => this.getChildren(id, {})));
|
||||
for (const id of path) {
|
||||
this.updateElement(id, {expanded: true});
|
||||
}
|
||||
this.onElementSelected()(path[path.length - 1]);
|
||||
}
|
||||
|
||||
getElementLeaves(tree: ElementSelectorNode): Array<ElementID> {
|
||||
return tree
|
||||
? Object.entries(tree).reduce(
|
||||
(
|
||||
currLeafNode: Array<ElementID>,
|
||||
[id, children]: [ElementID, ElementSelectorNode],
|
||||
): Array<ElementID> =>
|
||||
currLeafNode.concat(
|
||||
Object.keys(children).length > 0
|
||||
? this.getElementLeaves(children)
|
||||
: [id],
|
||||
),
|
||||
[],
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
/// Return path from given tree structure and id if id is not null; otherwise return any path
|
||||
getPathForNode(
|
||||
tree: ElementSelectorNode,
|
||||
nodeID: ElementID | null,
|
||||
): Array<ElementID> | null {
|
||||
for (const node in tree) {
|
||||
if (
|
||||
node === nodeID ||
|
||||
(nodeID === null && Object.keys(tree[node]).length == 0)
|
||||
) {
|
||||
return [node];
|
||||
}
|
||||
const path = this.getPathForNode(tree[node], nodeID);
|
||||
if (path !== null) {
|
||||
return [node].concat(path);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// NOTE: this will be used in the future when we remove path and use tree instead
|
||||
async _getAndExpandPathFromTree(tree: ElementSelectorNode) {
|
||||
this.getAndExpandPath(this.getPathForNode(tree, null) ?? []);
|
||||
}
|
||||
|
||||
onElementSelected = (option?: {
|
||||
cancelSelector?: boolean;
|
||||
expandPathToElement?: boolean;
|
||||
}) =>
|
||||
debounce(async (selectedKey: ElementID) => {
|
||||
if (option?.cancelSelector) {
|
||||
this.setState({elementSelector: null, axElementSelector: null});
|
||||
}
|
||||
if (option?.expandPathToElement) {
|
||||
const data = this.props.ax
|
||||
? this.state.axElementSelector
|
||||
: this.state.elementSelector;
|
||||
await this.getAndExpandPath(
|
||||
this.getPathForNode(data?.tree ?? {}, selectedKey) ?? [],
|
||||
);
|
||||
}
|
||||
this.onElementHovered(selectedKey);
|
||||
this.props.onSelect(selectedKey);
|
||||
});
|
||||
|
||||
onElementSelectedAtMainSection = this.onElementSelected({
|
||||
cancelSelector: true,
|
||||
});
|
||||
|
||||
onElementSelectedAndExpanded = this.onElementSelected({
|
||||
expandPathToElement: true,
|
||||
});
|
||||
|
||||
onElementHovered = debounce((key: ElementID | null | undefined) => {
|
||||
if (!this.props.client.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.props.client.call(this.call().SET_HIGHLIGHTED, {
|
||||
id: key,
|
||||
isAlignmentMode: this.props.inAlignmentMode,
|
||||
});
|
||||
});
|
||||
|
||||
onElementExpanded = (
|
||||
id: ElementID,
|
||||
deep: boolean,
|
||||
forceExpand: boolean = false,
|
||||
) => {
|
||||
const shouldExpand = forceExpand || !this.elements()[id].expanded;
|
||||
if (shouldExpand) {
|
||||
this.updateElement(id, {expanded: shouldExpand});
|
||||
}
|
||||
this.getChildren(id, {}).then((children) => {
|
||||
if (deep) {
|
||||
children.forEach((child) =>
|
||||
this.onElementExpanded(child.id, deep, shouldExpand),
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!shouldExpand) {
|
||||
this.updateElement(id, {expanded: shouldExpand});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const selectorData = this.props.ax
|
||||
? this.state.axElementSelector
|
||||
: this.state.elementSelector;
|
||||
|
||||
return this.root() ? (
|
||||
<ElementsInspectorContainer>
|
||||
<ElementsInspector
|
||||
onElementSelected={this.onElementSelectedAtMainSection}
|
||||
onElementHovered={this.onElementHovered}
|
||||
onElementExpanded={this.onElementExpanded}
|
||||
searchResults={this.props.searchResults}
|
||||
selected={this.selected()}
|
||||
root={this.root()}
|
||||
elements={this.elements()}
|
||||
focused={this.focused()}
|
||||
contextMenuExtensions={this.getAXContextMenuExtensions()}
|
||||
/>
|
||||
{selectorData && selectorData.leaves.length > 1 ? (
|
||||
<MultipleSelectorSection
|
||||
initialSelectedElement={this.selected()}
|
||||
elements={selectorData.elements}
|
||||
onElementSelected={this.onElementSelectedAndExpanded}
|
||||
onElementHovered={this.onElementHovered}
|
||||
/>
|
||||
) : null}
|
||||
</ElementsInspectorContainer>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
178
desktop/plugins/public/layout/InspectorSidebar.tsx
Normal file
178
desktop/plugins/public/layout/InspectorSidebar.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
ManagedDataInspector,
|
||||
Panel,
|
||||
FlexCenter,
|
||||
styled,
|
||||
colors,
|
||||
PluginClient,
|
||||
SidebarExtensions,
|
||||
Element,
|
||||
Client,
|
||||
Logger,
|
||||
} from 'flipper';
|
||||
import {Component} from 'react';
|
||||
import deepEqual from 'deep-equal';
|
||||
import React from 'react';
|
||||
import {useMemo, useEffect} from 'react';
|
||||
import {kebabCase} from 'lodash';
|
||||
|
||||
const NoData = styled(FlexCenter)({
|
||||
fontSize: 18,
|
||||
color: colors.macOSTitleBarIcon,
|
||||
});
|
||||
|
||||
type OnValueChanged = (path: Array<string>, val: any) => void;
|
||||
|
||||
type InspectorSidebarSectionProps = {
|
||||
data: any;
|
||||
id: string;
|
||||
onValueChanged: OnValueChanged | null;
|
||||
tooltips?: Object;
|
||||
};
|
||||
|
||||
class InspectorSidebarSection extends Component<InspectorSidebarSectionProps> {
|
||||
setValue = (path: Array<string>, value: any) => {
|
||||
if (this.props.onValueChanged) {
|
||||
this.props.onValueChanged([this.props.id, ...path], value);
|
||||
}
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps: InspectorSidebarSectionProps) {
|
||||
return (
|
||||
!deepEqual(nextProps, this.props) ||
|
||||
this.props.id !== nextProps.id ||
|
||||
this.props.onValueChanged !== nextProps.onValueChanged
|
||||
);
|
||||
}
|
||||
|
||||
extractValue = (val: any, _depth: number) => {
|
||||
if (val && val.__type__) {
|
||||
return {
|
||||
mutable: Boolean(val.__mutable__),
|
||||
type: val.__type__ === 'auto' ? typeof val.value : val.__type__,
|
||||
value: val.value,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
mutable: typeof val === 'object',
|
||||
type: typeof val,
|
||||
value: val,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {id} = this.props;
|
||||
return (
|
||||
<Panel heading={id} floating={false} grow={false}>
|
||||
<ManagedDataInspector
|
||||
data={this.props.data}
|
||||
setValue={this.props.onValueChanged ? this.setValue : undefined}
|
||||
extractValue={this.extractValue}
|
||||
expandRoot={true}
|
||||
collapsed={true}
|
||||
tooltips={this.props.tooltips}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
element: Element | null;
|
||||
tooltips?: Object;
|
||||
onValueChanged: OnValueChanged | null;
|
||||
client: PluginClient;
|
||||
realClient: Client;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
const Sidebar: React.FC<Props> = (props: Props) => {
|
||||
const {element} = props;
|
||||
|
||||
const [sectionDefs, sectionKeys] = useMemo(() => {
|
||||
const sectionKeys = [];
|
||||
const sectionDefs = [];
|
||||
|
||||
if (element && element.data)
|
||||
for (const key in element.data) {
|
||||
if (key === 'Extra Sections') {
|
||||
for (const extraSection in element.data[key]) {
|
||||
const section = element.data[key][extraSection];
|
||||
let data = {};
|
||||
|
||||
// data might be sent as stringified JSON, we want to parse it for a nicer persentation.
|
||||
if (typeof section === 'string') {
|
||||
try {
|
||||
data = JSON.parse(section);
|
||||
} catch (e) {
|
||||
// data was not a valid JSON, type is required to be an object
|
||||
console.error(
|
||||
`ElementsInspector unable to parse extra section: ${extraSection}`,
|
||||
);
|
||||
data = {};
|
||||
}
|
||||
} else {
|
||||
data = section;
|
||||
}
|
||||
sectionKeys.push(kebabCase(extraSection));
|
||||
sectionDefs.push({
|
||||
key: extraSection,
|
||||
id: extraSection,
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
sectionKeys.push(kebabCase(key));
|
||||
sectionDefs.push({
|
||||
key,
|
||||
id: key,
|
||||
data: element.data[key],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [sectionDefs, sectionKeys];
|
||||
}, [props.element]);
|
||||
|
||||
const sections: Array<React.ReactNode> = (
|
||||
(SidebarExtensions &&
|
||||
element?.data &&
|
||||
SidebarExtensions.map((ext) =>
|
||||
ext(props.client, props.realClient, element, props.logger),
|
||||
)) ||
|
||||
[]
|
||||
).concat(
|
||||
sectionDefs.map((def) => (
|
||||
<InspectorSidebarSection
|
||||
tooltips={props.tooltips}
|
||||
key={def.key}
|
||||
id={def.id}
|
||||
data={def.data}
|
||||
onValueChanged={props.onValueChanged}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
sectionKeys.map((key) =>
|
||||
props.logger.track('usage', `layout-sidebar-extension:${key}:loaded`),
|
||||
);
|
||||
}, [props.element?.data]);
|
||||
|
||||
if (!element || !element.data) {
|
||||
return <NoData grow>No data</NoData>;
|
||||
}
|
||||
return <>{sections}</>;
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
97
desktop/plugins/public/layout/MultipleSelectionSection.tsx
Normal file
97
desktop/plugins/public/layout/MultipleSelectionSection.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
FlexColumn,
|
||||
FlexBox,
|
||||
Element,
|
||||
ElementsConstants,
|
||||
ElementID,
|
||||
ElementsInspector,
|
||||
Glyph,
|
||||
colors,
|
||||
styled,
|
||||
} from 'flipper';
|
||||
import React, {memo, useState} from 'react';
|
||||
|
||||
const MultipleSelectorSectionContainer = styled(FlexColumn)({
|
||||
maxHeight: 3 * ElementsConstants.rowHeight + 24,
|
||||
});
|
||||
|
||||
const MultipleSelectorSectionTitle = styled(FlexBox)({
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#f6f7f9',
|
||||
padding: '2px',
|
||||
paddingLeft: '9px',
|
||||
width: '325px',
|
||||
height: '20px',
|
||||
fontWeight: 500,
|
||||
boxShadow: '2px 2px 2px #ccc',
|
||||
border: `1px solid ${colors.light20}`,
|
||||
borderTopLeftRadius: '4px',
|
||||
borderTopRightRadius: '4px',
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
const Chevron = styled(Glyph)({
|
||||
marginRight: 4,
|
||||
marginLeft: -2,
|
||||
marginBottom: 1,
|
||||
});
|
||||
|
||||
type MultipleSelectorSectionProps = {
|
||||
initialSelectedElement: ElementID | null | undefined;
|
||||
elements: {[id: string]: Element};
|
||||
onElementSelected: (key: string) => void;
|
||||
onElementHovered:
|
||||
| ((key: string | null | undefined) => any)
|
||||
| null
|
||||
| undefined;
|
||||
};
|
||||
|
||||
const MultipleSelectorSection: React.FC<MultipleSelectorSectionProps> = memo(
|
||||
(props: MultipleSelectorSectionProps) => {
|
||||
const {
|
||||
initialSelectedElement,
|
||||
elements,
|
||||
onElementSelected,
|
||||
onElementHovered,
|
||||
} = props;
|
||||
const [selectedId, setSelectedId] = useState<ElementID | null | undefined>(
|
||||
initialSelectedElement,
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
return (
|
||||
<MultipleSelectorSectionContainer>
|
||||
<MultipleSelectorSectionTitle
|
||||
onClick={() => {
|
||||
setCollapsed(!collapsed);
|
||||
}}>
|
||||
<Chevron name={collapsed ? 'chevron-up' : 'chevron-down'} size={12} />
|
||||
Multiple elements found at the target coordinates
|
||||
</MultipleSelectorSectionTitle>
|
||||
{!collapsed && (
|
||||
<ElementsInspector
|
||||
onElementSelected={(key: string) => {
|
||||
setSelectedId(key);
|
||||
onElementSelected(key);
|
||||
}}
|
||||
onElementHovered={onElementHovered}
|
||||
onElementExpanded={() => {}}
|
||||
root={null}
|
||||
selected={selectedId}
|
||||
elements={elements}
|
||||
/>
|
||||
)}
|
||||
</MultipleSelectorSectionContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default MultipleSelectorSection;
|
||||
197
desktop/plugins/public/layout/ProxyArchiveClient.tsx
Normal file
197
desktop/plugins/public/layout/ProxyArchiveClient.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Element} from 'flipper';
|
||||
import {PersistedState} from './index';
|
||||
import {SearchResultTree} from './Search';
|
||||
import {cloneDeep} from 'lodash';
|
||||
|
||||
const propsForPersistedState = (
|
||||
AXMode: boolean,
|
||||
): {
|
||||
ROOT: 'rootAXElement' | 'rootElement';
|
||||
ELEMENTS: 'AXelements' | 'elements';
|
||||
ELEMENT: 'axElement' | 'element';
|
||||
} => {
|
||||
return {
|
||||
ROOT: AXMode ? 'rootAXElement' : 'rootElement',
|
||||
ELEMENTS: AXMode ? 'AXelements' : 'elements',
|
||||
ELEMENT: AXMode ? 'axElement' : 'element',
|
||||
};
|
||||
};
|
||||
|
||||
function constructSearchResultTree(
|
||||
node: Element,
|
||||
isMatch: boolean,
|
||||
children: Array<SearchResultTree>,
|
||||
_AXMode: boolean,
|
||||
AXNode: Element | null,
|
||||
): SearchResultTree {
|
||||
const searchResult = {
|
||||
id: node.id,
|
||||
isMatch,
|
||||
hasChildren: children.length > 0,
|
||||
children: children.length > 0 ? children : [],
|
||||
element: node,
|
||||
axElement: AXNode,
|
||||
};
|
||||
return searchResult;
|
||||
}
|
||||
|
||||
function isMatch(element: Element, query: string): boolean {
|
||||
const nameMatch = element.name.toLowerCase().includes(query.toLowerCase());
|
||||
return nameMatch || element.id === query;
|
||||
}
|
||||
|
||||
export function searchNodes(
|
||||
node: Element,
|
||||
query: string,
|
||||
AXMode: boolean,
|
||||
state: PersistedState,
|
||||
): SearchResultTree | null {
|
||||
// Even if the axMode is true, we will have to search the normal elements too.
|
||||
// The AXEelements will automatically populated in constructSearchResultTree
|
||||
const elements = state[propsForPersistedState(false).ELEMENTS];
|
||||
const children: Array<SearchResultTree> = [];
|
||||
const match = isMatch(node, query);
|
||||
|
||||
for (const childID of node.children) {
|
||||
const child = elements[childID];
|
||||
const tree = searchNodes(child, query, AXMode, state);
|
||||
if (tree) {
|
||||
children.push(tree);
|
||||
}
|
||||
}
|
||||
|
||||
if (match || children.length > 0) {
|
||||
return cloneDeep(
|
||||
constructSearchResultTree(
|
||||
node,
|
||||
match,
|
||||
children,
|
||||
AXMode,
|
||||
AXMode ? state.AXelements[node.id] : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class ProxyArchiveClient {
|
||||
constructor(
|
||||
persistedState: PersistedState,
|
||||
onElementHighlighted?: (id: string) => void,
|
||||
) {
|
||||
this.persistedState = cloneDeep(persistedState);
|
||||
this.onElementHighlighted = onElementHighlighted;
|
||||
}
|
||||
|
||||
isConnected = true;
|
||||
|
||||
persistedState: PersistedState;
|
||||
onElementHighlighted: ((id: string) => void) | undefined;
|
||||
subscribe(_method: string, _callback: (params: any) => void): void {
|
||||
return;
|
||||
}
|
||||
|
||||
supportsMethod(_method: string): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
send(_method: string, _params?: Object): void {
|
||||
return;
|
||||
}
|
||||
|
||||
call(method: string, paramaters?: {[key: string]: any}): Promise<any> {
|
||||
switch (method) {
|
||||
case 'getRoot': {
|
||||
const {rootElement} = this.persistedState;
|
||||
if (!rootElement) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.resolve(this.persistedState.elements[rootElement]);
|
||||
}
|
||||
case 'getAXRoot': {
|
||||
const {rootAXElement} = this.persistedState;
|
||||
if (!rootAXElement) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.resolve(this.persistedState.AXelements[rootAXElement]);
|
||||
}
|
||||
case 'getNodes': {
|
||||
if (!paramaters) {
|
||||
return Promise.reject(new Error('Called getNodes with no params'));
|
||||
}
|
||||
const {ids} = paramaters;
|
||||
const arr: Array<Element> = [];
|
||||
for (const id of ids) {
|
||||
arr.push(this.persistedState.elements[id]);
|
||||
}
|
||||
return Promise.resolve({elements: arr});
|
||||
}
|
||||
case 'getAXNodes': {
|
||||
if (!paramaters) {
|
||||
return Promise.reject(new Error('Called getAXNodes with no params'));
|
||||
}
|
||||
const {ids} = paramaters;
|
||||
const arr: Array<Element> = [];
|
||||
for (const id of ids) {
|
||||
arr.push(this.persistedState.AXelements[id]);
|
||||
}
|
||||
return Promise.resolve({elements: arr});
|
||||
}
|
||||
case 'getSearchResults': {
|
||||
const {rootElement, rootAXElement} = this.persistedState;
|
||||
|
||||
if (!paramaters) {
|
||||
return Promise.reject(
|
||||
new Error('Called getSearchResults with no params'),
|
||||
);
|
||||
}
|
||||
const {query, axEnabled} = paramaters;
|
||||
if (!query) {
|
||||
return Promise.reject(
|
||||
new Error('query is not passed as a params to getSearchResults'),
|
||||
);
|
||||
}
|
||||
let element: Element;
|
||||
if (axEnabled) {
|
||||
if (!rootAXElement) {
|
||||
return Promise.reject(new Error('rootAXElement is undefined'));
|
||||
}
|
||||
element = this.persistedState.AXelements[rootAXElement];
|
||||
} else {
|
||||
if (!rootElement) {
|
||||
return Promise.reject(new Error('rootElement is undefined'));
|
||||
}
|
||||
element = this.persistedState.elements[rootElement];
|
||||
}
|
||||
const output = searchNodes(
|
||||
element,
|
||||
query,
|
||||
axEnabled,
|
||||
this.persistedState,
|
||||
);
|
||||
return Promise.resolve({results: output, query});
|
||||
}
|
||||
case 'isConsoleEnabled': {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
case 'setHighlighted': {
|
||||
const id = paramaters?.id;
|
||||
this.onElementHighlighted && this.onElementHighlighted(id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
default: {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export default ProxyArchiveClient;
|
||||
211
desktop/plugins/public/layout/Search.tsx
Normal file
211
desktop/plugins/public/layout/Search.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {PersistedState, ElementMap} from './';
|
||||
import {
|
||||
PluginClient,
|
||||
ElementSearchResultSet,
|
||||
Element,
|
||||
SearchInput,
|
||||
SearchBox,
|
||||
SearchIcon,
|
||||
LoadingIndicator,
|
||||
styled,
|
||||
colors,
|
||||
} from 'flipper';
|
||||
import {Component} from 'react';
|
||||
import React from 'react';
|
||||
|
||||
export type SearchResultTree = {
|
||||
id: string;
|
||||
isMatch: boolean;
|
||||
hasChildren: boolean;
|
||||
children: Array<SearchResultTree>;
|
||||
element: Element;
|
||||
axElement: Element | null; // Not supported in iOS
|
||||
};
|
||||
|
||||
type Props = {
|
||||
client: PluginClient;
|
||||
inAXMode: boolean;
|
||||
onSearchResults: (searchResults: ElementSearchResultSet) => void;
|
||||
setPersistedState: (state: Partial<PersistedState>) => void;
|
||||
persistedState: PersistedState;
|
||||
initialQuery: string | null;
|
||||
};
|
||||
|
||||
type State = {
|
||||
value: string;
|
||||
outstandingSearchQuery: string | null;
|
||||
};
|
||||
|
||||
const LoadingSpinner = styled(LoadingIndicator)({
|
||||
marginRight: 4,
|
||||
marginLeft: 3,
|
||||
marginTop: -1,
|
||||
});
|
||||
|
||||
export default class Search extends Component<Props, State> {
|
||||
state = {
|
||||
value: '',
|
||||
outstandingSearchQuery: null,
|
||||
};
|
||||
|
||||
timer: NodeJS.Timeout | undefined;
|
||||
|
||||
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
const {value} = e.target;
|
||||
this.setState({value});
|
||||
this.timer = setTimeout(() => this.performSearch(value), 200);
|
||||
};
|
||||
|
||||
onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.performSearch(this.state.value);
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.initialQuery) {
|
||||
const queryString = this.props.initialQuery
|
||||
? this.props.initialQuery
|
||||
: '';
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.timer = setTimeout(() => this.performSearch(queryString), 200);
|
||||
}
|
||||
}
|
||||
|
||||
performSearch(query: string) {
|
||||
this.setState({
|
||||
outstandingSearchQuery: query,
|
||||
});
|
||||
|
||||
if (!query) {
|
||||
this.displaySearchResults(
|
||||
{query: '', results: null},
|
||||
this.props.inAXMode,
|
||||
);
|
||||
} else {
|
||||
this.props.client
|
||||
.call('getSearchResults', {query, axEnabled: this.props.inAXMode})
|
||||
.then((response) =>
|
||||
this.displaySearchResults(response, this.props.inAXMode),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
displaySearchResults(
|
||||
{
|
||||
results,
|
||||
query,
|
||||
}: {
|
||||
results: SearchResultTree | null;
|
||||
query: string;
|
||||
},
|
||||
axMode: boolean,
|
||||
) {
|
||||
this.setState({
|
||||
outstandingSearchQuery:
|
||||
query === this.state.outstandingSearchQuery
|
||||
? null
|
||||
: this.state.outstandingSearchQuery,
|
||||
});
|
||||
|
||||
const searchResults = this.getElementsFromSearchResultTree(results);
|
||||
const searchResultIDs = new Set(searchResults.map((r) => r.element.id));
|
||||
const elements: ElementMap = searchResults.reduce(
|
||||
(acc: ElementMap, {element}: SearchResultTree) => ({
|
||||
...acc,
|
||||
[element.id]: {
|
||||
...element,
|
||||
// expand all search results, that we have have children for
|
||||
expanded: element.children.some((c) => searchResultIDs.has(c)),
|
||||
},
|
||||
}),
|
||||
this.props.persistedState.elements,
|
||||
);
|
||||
|
||||
let {AXelements} = this.props.persistedState;
|
||||
if (axMode) {
|
||||
AXelements = searchResults.reduce(
|
||||
(acc: ElementMap, {axElement}: SearchResultTree) => {
|
||||
if (!axElement) {
|
||||
return acc;
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
[axElement.id]: {
|
||||
...axElement,
|
||||
// expand all search results, that we have have children for
|
||||
expanded: axElement.children.some((c) => searchResultIDs.has(c)),
|
||||
},
|
||||
};
|
||||
},
|
||||
this.props.persistedState.AXelements,
|
||||
);
|
||||
}
|
||||
|
||||
this.props.setPersistedState({elements, AXelements});
|
||||
|
||||
this.props.onSearchResults({
|
||||
matches: new Set(
|
||||
searchResults.filter((x) => x.isMatch).map((x) => x.element.id),
|
||||
),
|
||||
query: query,
|
||||
});
|
||||
}
|
||||
|
||||
getElementsFromSearchResultTree(
|
||||
tree: SearchResultTree | null,
|
||||
): Array<SearchResultTree> {
|
||||
if (!tree) {
|
||||
return [];
|
||||
}
|
||||
let elements = [
|
||||
{
|
||||
children: [] as Array<SearchResultTree>,
|
||||
id: tree.id,
|
||||
isMatch: tree.isMatch,
|
||||
hasChildren: Boolean(tree.children),
|
||||
element: tree.element,
|
||||
axElement: tree.axElement,
|
||||
},
|
||||
];
|
||||
if (tree.children) {
|
||||
for (const child of tree.children) {
|
||||
elements = elements.concat(this.getElementsFromSearchResultTree(child));
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SearchBox tabIndex={-1}>
|
||||
<SearchIcon
|
||||
name="magnifying-glass"
|
||||
color={colors.macOSTitleBarIcon}
|
||||
size={16}
|
||||
/>
|
||||
<SearchInput
|
||||
placeholder={'Search'}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
value={this.state.value}
|
||||
/>
|
||||
{this.state.outstandingSearchQuery && <LoadingSpinner size={16} />}
|
||||
</SearchBox>
|
||||
);
|
||||
}
|
||||
}
|
||||
121
desktop/plugins/public/layout/__tests__/Inspector.node.tsx
Normal file
121
desktop/plugins/public/layout/__tests__/Inspector.node.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import Inspector, {ElementSelectorNode} from '../Inspector';
|
||||
import {PluginClient, Element} from 'flipper';
|
||||
import React from 'react';
|
||||
import {render} from '@testing-library/react';
|
||||
|
||||
let inspectorComponent: Inspector | null = null;
|
||||
beforeEach(() => {
|
||||
const mockRoot: Element = {
|
||||
id: '10000',
|
||||
name: '10000',
|
||||
expanded: false,
|
||||
children: [],
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: '',
|
||||
extraInfo: {},
|
||||
};
|
||||
const client: PluginClient = {
|
||||
isConnected: true,
|
||||
send: () => {},
|
||||
call: () => Promise.resolve(mockRoot),
|
||||
subscribe: () => {},
|
||||
supportsMethod: () => Promise.resolve(false),
|
||||
};
|
||||
render(
|
||||
<Inspector
|
||||
client={client}
|
||||
showsSidebar={false}
|
||||
selectedElement={null}
|
||||
selectedAXElement={null}
|
||||
onSelect={() => {}}
|
||||
setPersistedState={() => {}}
|
||||
persistedState={{
|
||||
rootElement: null,
|
||||
rootAXElement: null,
|
||||
elements: {},
|
||||
AXelements: {},
|
||||
}}
|
||||
searchResults={null}
|
||||
ref={(e) => {
|
||||
inspectorComponent = e;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
function constructTestTree(): ElementSelectorNode {
|
||||
// The tree will be:
|
||||
// 10000 ---> 11000 ---> 11100 ---> 11110
|
||||
// | | +-> 11120
|
||||
// | +-> 11200
|
||||
// +--> 12000 ---> 12100
|
||||
// +-> 12200 ---> 12210 ---> 12211
|
||||
// +-> 12300 ---> 12310
|
||||
// +-> 12320
|
||||
return {
|
||||
10000: {
|
||||
11000: {11100: {11110: {}, 11120: {}}, 11200: {}},
|
||||
12000: {
|
||||
12100: {},
|
||||
12200: {12210: {12211: {}}},
|
||||
12300: {12310: {}, 12320: {}},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('test getPathFromNode without id', () => {
|
||||
const tree = constructTestTree();
|
||||
const path = inspectorComponent?.getPathForNode(tree, null);
|
||||
let subtree = tree;
|
||||
path?.forEach((id) => {
|
||||
subtree = subtree[id];
|
||||
expect(subtree).toBeDefined();
|
||||
});
|
||||
expect(subtree).toEqual({});
|
||||
});
|
||||
|
||||
test('test getPathFromNode with id (leaf)', () => {
|
||||
const tree = constructTestTree();
|
||||
const path = inspectorComponent?.getPathForNode(tree, '12320');
|
||||
expect(path).toEqual(['10000', '12000', '12300', '12320']);
|
||||
});
|
||||
|
||||
test('test getPathFromNode with id (non-leaf)', () => {
|
||||
const tree = constructTestTree();
|
||||
const path = inspectorComponent?.getPathForNode(tree, '12210');
|
||||
expect(path).toEqual(['10000', '12000', '12200', '12210']);
|
||||
});
|
||||
|
||||
test('test getPathFromNode with non-existing id', () => {
|
||||
const tree = constructTestTree();
|
||||
const path = inspectorComponent?.getPathForNode(tree, '12313');
|
||||
expect(path).toBeNull();
|
||||
});
|
||||
|
||||
test('test getElementLeaves', () => {
|
||||
const tree = constructTestTree();
|
||||
const leaves = inspectorComponent?.getElementLeaves(tree);
|
||||
expect(leaves).toHaveLength(7);
|
||||
expect(leaves).toEqual(
|
||||
expect.arrayContaining([
|
||||
'11110',
|
||||
'11120',
|
||||
'11200',
|
||||
'12100',
|
||||
'12211',
|
||||
'12310',
|
||||
'12320',
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {render, fireEvent} from '@testing-library/react';
|
||||
|
||||
import {Element} from 'flipper';
|
||||
import MultipleSelectorSection from '../MultipleSelectionSection';
|
||||
|
||||
const TITLE_STRING = 'Multiple elements found at the target coordinates';
|
||||
const dummyElmentData: Omit<Element, 'id' | 'name'> = {
|
||||
expanded: false,
|
||||
children: [],
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: '',
|
||||
extraInfo: {},
|
||||
};
|
||||
|
||||
test('rendering a single element', () => {
|
||||
const id = 'id1';
|
||||
const name = 'id_name';
|
||||
const element: Element = {...dummyElmentData, id, name};
|
||||
const res = render(
|
||||
<MultipleSelectorSection
|
||||
initialSelectedElement={null}
|
||||
elements={{[id]: element}}
|
||||
onElementSelected={() => {}}
|
||||
onElementHovered={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(res.queryByText(TITLE_STRING)).toBeDefined();
|
||||
expect(res.queryAllByText(name).length).toBe(1);
|
||||
});
|
||||
|
||||
test('collapsing an element', () => {
|
||||
const id = 'id1';
|
||||
const name = 'id_name';
|
||||
const element: Element = {...dummyElmentData, id, name};
|
||||
const res = render(
|
||||
<MultipleSelectorSection
|
||||
initialSelectedElement={null}
|
||||
elements={{[id]: element}}
|
||||
onElementSelected={() => {}}
|
||||
onElementHovered={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(res.queryAllByText(name).length).toBe(1);
|
||||
|
||||
// collapse the view
|
||||
fireEvent.click(res.getByText(TITLE_STRING));
|
||||
expect(res.queryAllByText(name).length).toBe(0);
|
||||
|
||||
// re-expand the view
|
||||
fireEvent.click(res.getByText(TITLE_STRING));
|
||||
expect(res.queryAllByText(name).length).toBe(1);
|
||||
});
|
||||
|
||||
test('clicking on elements', () => {
|
||||
const ids = ['id1', 'id2', 'id3'];
|
||||
const names = ['id_name_first', 'id_name_second', 'id_name_third'];
|
||||
const elements: {[id: string]: Element} = ids.reduce(
|
||||
(acc: {[id: string]: Element}, id, idx) => {
|
||||
acc[id] = {...dummyElmentData, id, name: names[idx]};
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const mockOnElementSelected = jest.fn((_key: string) => {});
|
||||
window.scrollTo = () => {};
|
||||
const res = render(
|
||||
<MultipleSelectorSection
|
||||
initialSelectedElement={null}
|
||||
elements={elements}
|
||||
onElementSelected={mockOnElementSelected}
|
||||
onElementHovered={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickingIdx = [0, 1, 2, 1, 0];
|
||||
clickingIdx.forEach((idx) => fireEvent.click(res.getByText(names[idx])));
|
||||
|
||||
// expect all click to call the function
|
||||
expect(mockOnElementSelected.mock.calls.length).toBe(clickingIdx.length);
|
||||
clickingIdx.forEach((valIdx, idx) =>
|
||||
expect(mockOnElementSelected.mock.calls[idx][0]).toBe(ids[valIdx]),
|
||||
);
|
||||
});
|
||||
|
||||
test('hovering on elements', () => {
|
||||
const ids = ['id1', 'id2', 'id3'];
|
||||
const names = ['id_name_first', 'id_name_second', 'id_name_third'];
|
||||
const elements: {[id: string]: Element} = ids.reduce(
|
||||
(acc: {[id: string]: Element}, id, idx) => {
|
||||
acc[id] = {...dummyElmentData, id, name: names[idx]};
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const mockOnElementSelected = jest.fn((_key: string) => {});
|
||||
const mockOnElementHovered = jest.fn((_key: string | null | undefined) => {});
|
||||
window.scrollTo = () => {};
|
||||
const res = render(
|
||||
<MultipleSelectorSection
|
||||
initialSelectedElement={null}
|
||||
elements={elements}
|
||||
onElementSelected={mockOnElementSelected}
|
||||
onElementHovered={mockOnElementHovered}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickingIdx = [0, 1, 2, 1, 0];
|
||||
clickingIdx.forEach((idx) => fireEvent.mouseOver(res.getByText(names[idx])));
|
||||
|
||||
// expect all hover to call the function
|
||||
expect(mockOnElementHovered.mock.calls.length).toBe(clickingIdx.length);
|
||||
clickingIdx.forEach((valIdx, idx) =>
|
||||
expect(mockOnElementHovered.mock.calls[idx][0]).toBe(ids[valIdx]),
|
||||
);
|
||||
|
||||
// expect no click to be called
|
||||
expect(mockOnElementSelected.mock.calls.length).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
default as ProxyArchiveClient,
|
||||
searchNodes,
|
||||
} from '../ProxyArchiveClient';
|
||||
import {PersistedState, ElementMap} from '../index';
|
||||
import {ElementID, Element} from 'flipper';
|
||||
import {SearchResultTree} from '../Search';
|
||||
|
||||
function constructElement(
|
||||
id: string,
|
||||
name: string,
|
||||
children: Array<ElementID>,
|
||||
): Element {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
expanded: false,
|
||||
children,
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: 'decoration',
|
||||
extraInfo: {},
|
||||
};
|
||||
}
|
||||
|
||||
function constructPersistedState(axMode: boolean): PersistedState {
|
||||
if (!axMode) {
|
||||
return {
|
||||
rootElement: 'root',
|
||||
rootAXElement: null,
|
||||
elements: {},
|
||||
AXelements: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
rootElement: null,
|
||||
rootAXElement: 'root',
|
||||
elements: {},
|
||||
AXelements: {},
|
||||
};
|
||||
}
|
||||
let state = constructPersistedState(false);
|
||||
|
||||
function populateChildren(state: PersistedState, axMode: boolean) {
|
||||
const elements: ElementMap = {};
|
||||
elements['root'] = constructElement('root', 'root view', [
|
||||
'child0',
|
||||
'child1',
|
||||
]);
|
||||
|
||||
elements['child0'] = constructElement('child0', 'child0 view', [
|
||||
'child0_child0',
|
||||
'child0_child1',
|
||||
]);
|
||||
elements['child1'] = constructElement('child1', 'child1 view', [
|
||||
'child1_child0',
|
||||
'child1_child1',
|
||||
]);
|
||||
elements['child0_child0'] = constructElement(
|
||||
'child0_child0',
|
||||
'child0_child0 view',
|
||||
[],
|
||||
);
|
||||
elements['child0_child1'] = constructElement(
|
||||
'child0_child1',
|
||||
'child0_child1 view',
|
||||
[],
|
||||
);
|
||||
elements['child1_child0'] = constructElement(
|
||||
'child1_child0',
|
||||
'child1_child0 view',
|
||||
[],
|
||||
);
|
||||
elements['child1_child1'] = constructElement(
|
||||
'child1_child1',
|
||||
'child1_child1 view',
|
||||
[],
|
||||
);
|
||||
state.elements = elements;
|
||||
if (axMode) {
|
||||
state.AXelements = elements;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state = constructPersistedState(false);
|
||||
populateChildren(state, false);
|
||||
});
|
||||
|
||||
test('test the searchNode for root in axMode false', async () => {
|
||||
const searchResult: SearchResultTree | null = await searchNodes(
|
||||
state.elements['root'],
|
||||
'root',
|
||||
false,
|
||||
state,
|
||||
);
|
||||
expect(searchResult).toBeDefined();
|
||||
expect(searchResult).toEqual({
|
||||
id: 'root',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['root'],
|
||||
axElement: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('test the searchNode for root in axMode true', async () => {
|
||||
state = constructPersistedState(true);
|
||||
populateChildren(state, true);
|
||||
const searchResult: SearchResultTree | null = await searchNodes(
|
||||
state.AXelements['root'],
|
||||
'RoOT',
|
||||
true,
|
||||
state,
|
||||
);
|
||||
expect(searchResult).toBeDefined();
|
||||
expect(searchResult).toEqual({
|
||||
id: 'root',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.AXelements['root'], // Even though AXElement exists, normal element will exist too
|
||||
axElement: state.AXelements['root'],
|
||||
});
|
||||
});
|
||||
|
||||
test('test the searchNode which matches just one child', async () => {
|
||||
const searchResult: SearchResultTree | null = await searchNodes(
|
||||
state.elements['root'],
|
||||
'child0_child0',
|
||||
false,
|
||||
state,
|
||||
);
|
||||
expect(searchResult).toBeDefined();
|
||||
expect(searchResult).toEqual({
|
||||
id: 'root',
|
||||
isMatch: false,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child0',
|
||||
isMatch: false,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child0_child0',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child0_child0'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['child0'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['root'],
|
||||
axElement: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('test the searchNode for which matches multiple child', async () => {
|
||||
const searchResult: SearchResultTree | null = await searchNodes(
|
||||
state.elements['root'],
|
||||
'child0',
|
||||
false,
|
||||
state,
|
||||
);
|
||||
expect(searchResult).toBeDefined();
|
||||
const expectedSearchResult = {
|
||||
id: 'root',
|
||||
isMatch: false,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child0',
|
||||
isMatch: true,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child0_child0',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child0_child0'],
|
||||
axElement: null,
|
||||
},
|
||||
{
|
||||
id: 'child0_child1',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child0_child1'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['child0'],
|
||||
axElement: null,
|
||||
},
|
||||
{
|
||||
id: 'child1',
|
||||
isMatch: false,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child1_child0',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child1_child0'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['child1'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['root'],
|
||||
axElement: null,
|
||||
};
|
||||
expect(searchResult).toEqual(expectedSearchResult);
|
||||
});
|
||||
|
||||
test('test the searchNode, it should not be case sensitive', async () => {
|
||||
const searchResult: SearchResultTree | null = await searchNodes(
|
||||
state.elements['root'],
|
||||
'ChIlD0',
|
||||
false,
|
||||
state,
|
||||
);
|
||||
expect(searchResult).toBeDefined();
|
||||
const expectedSearchResult = {
|
||||
id: 'root',
|
||||
isMatch: false,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child0',
|
||||
isMatch: true,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child0_child0',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child0_child0'],
|
||||
axElement: null,
|
||||
},
|
||||
{
|
||||
id: 'child0_child1',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child0_child1'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['child0'],
|
||||
axElement: null,
|
||||
},
|
||||
{
|
||||
id: 'child1',
|
||||
isMatch: false,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child1_child0',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child1_child0'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['child1'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['root'],
|
||||
axElement: null,
|
||||
};
|
||||
expect(searchResult).toEqual(expectedSearchResult);
|
||||
});
|
||||
|
||||
test('test the searchNode for non existent query', async () => {
|
||||
const searchResult: SearchResultTree | null = await searchNodes(
|
||||
state.elements['root'],
|
||||
'Unknown query',
|
||||
false,
|
||||
state,
|
||||
);
|
||||
expect(searchResult).toBeNull();
|
||||
});
|
||||
|
||||
test('test the call method with getRoot', async () => {
|
||||
const proxyClient = new ProxyArchiveClient(state);
|
||||
const root: Element = await proxyClient.call('getRoot');
|
||||
expect(root).toEqual(state.elements['root']);
|
||||
});
|
||||
|
||||
test('test the call method with getAXRoot', async () => {
|
||||
state = constructPersistedState(true);
|
||||
populateChildren(state, true);
|
||||
const proxyClient = new ProxyArchiveClient(state);
|
||||
const root: Element = await proxyClient.call('getAXRoot');
|
||||
expect(root).toEqual(state.AXelements['root']);
|
||||
});
|
||||
|
||||
test('test the call method with getNodes', async () => {
|
||||
const proxyClient = new ProxyArchiveClient(state);
|
||||
const nodes: Array<Element> = await proxyClient.call('getNodes', {
|
||||
ids: ['child0_child1', 'child1_child0'],
|
||||
});
|
||||
expect(nodes).toEqual({
|
||||
elements: [
|
||||
{
|
||||
id: 'child0_child1',
|
||||
name: 'child0_child1 view',
|
||||
expanded: false,
|
||||
children: [],
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: 'decoration',
|
||||
extraInfo: {},
|
||||
},
|
||||
{
|
||||
id: 'child1_child0',
|
||||
name: 'child1_child0 view',
|
||||
expanded: false,
|
||||
children: [],
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: 'decoration',
|
||||
extraInfo: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('test the call method with getAXNodes', async () => {
|
||||
state = constructPersistedState(true);
|
||||
populateChildren(state, true);
|
||||
const proxyClient = new ProxyArchiveClient(state);
|
||||
const nodes: Array<Element> = await proxyClient.call('getAXNodes', {
|
||||
ids: ['child0_child1', 'child1_child0'],
|
||||
});
|
||||
expect(nodes).toEqual({
|
||||
elements: [
|
||||
{
|
||||
id: 'child0_child1',
|
||||
name: 'child0_child1 view',
|
||||
expanded: false,
|
||||
children: [],
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: 'decoration',
|
||||
extraInfo: {},
|
||||
},
|
||||
{
|
||||
id: 'child1_child0',
|
||||
name: 'child1_child0 view',
|
||||
expanded: false,
|
||||
children: [],
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: 'decoration',
|
||||
extraInfo: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('test different methods of calls with no params', async () => {
|
||||
const proxyClient = new ProxyArchiveClient(state);
|
||||
await expect(proxyClient.call('getNodes')).rejects.toThrow(
|
||||
new Error('Called getNodes with no params'),
|
||||
);
|
||||
await expect(proxyClient.call('getAXNodes')).rejects.toThrow(
|
||||
new Error('Called getAXNodes with no params'),
|
||||
);
|
||||
// let result: Error = await proxyClient.call('getSearchResults');
|
||||
await expect(proxyClient.call('getSearchResults')).rejects.toThrow(
|
||||
new Error('Called getSearchResults with no params'),
|
||||
);
|
||||
await expect(
|
||||
proxyClient.call('getSearchResults', {
|
||||
query: 'random',
|
||||
axEnabled: true,
|
||||
}),
|
||||
).rejects.toThrow(new Error('rootAXElement is undefined'));
|
||||
await expect(
|
||||
proxyClient.call('getSearchResults', {
|
||||
axEnabled: false,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
new Error('query is not passed as a params to getSearchResults'),
|
||||
);
|
||||
});
|
||||
|
||||
test('test call method isConsoleEnabled', () => {
|
||||
const proxyClient = new ProxyArchiveClient(state);
|
||||
return expect(proxyClient.call('isConsoleEnabled')).resolves.toBe(false);
|
||||
});
|
||||
560
desktop/plugins/public/layout/index.tsx
Normal file
560
desktop/plugins/public/layout/index.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
ElementID,
|
||||
Element,
|
||||
ElementSearchResultSet,
|
||||
PluginClient,
|
||||
FlipperPlugin,
|
||||
Toolbar,
|
||||
DetailSidebar,
|
||||
Button,
|
||||
GK,
|
||||
Idler,
|
||||
ReduxState,
|
||||
ArchivedDevice,
|
||||
ToolbarIcon,
|
||||
Layout,
|
||||
Sidebar,
|
||||
} from 'flipper';
|
||||
import Inspector from './Inspector';
|
||||
import InspectorSidebar from './InspectorSidebar';
|
||||
import Search from './Search';
|
||||
import ProxyArchiveClient from './ProxyArchiveClient';
|
||||
import React from 'react';
|
||||
import {
|
||||
VisualizerPortal,
|
||||
getFlipperMediaCDN,
|
||||
IDEFileResolver,
|
||||
IDEType,
|
||||
} from 'flipper';
|
||||
|
||||
type State = {
|
||||
init: boolean;
|
||||
inTargetMode: boolean;
|
||||
inAXMode: boolean;
|
||||
inAlignmentMode: boolean;
|
||||
selectedElement: ElementID | null | undefined;
|
||||
selectedAXElement: ElementID | null | undefined;
|
||||
highlightedElement: ElementID | null;
|
||||
searchResults: ElementSearchResultSet | null;
|
||||
visualizerWindow: Window | null;
|
||||
visualizerScreenshot: string | null;
|
||||
screenDimensions: {width: number; height: number} | null;
|
||||
};
|
||||
|
||||
export type ElementMap = {[key: string]: Element};
|
||||
|
||||
export type PersistedState = {
|
||||
rootElement: ElementID | null;
|
||||
rootAXElement: ElementID | null;
|
||||
elements: ElementMap;
|
||||
AXelements: ElementMap;
|
||||
};
|
||||
type ClientGetNodesCalls = 'getNodes' | 'getAXNodes';
|
||||
type ClientMethodCalls = 'getRoot' | 'getAXRoot' | ClientGetNodesCalls;
|
||||
|
||||
type ClassFileParams = {
|
||||
fileName: string;
|
||||
className: string;
|
||||
dirRoot: string;
|
||||
};
|
||||
|
||||
type OpenFileParams = {
|
||||
resolvedPath: string;
|
||||
ide: IDEType;
|
||||
repo: string;
|
||||
lineNumber: number;
|
||||
};
|
||||
|
||||
export default class LayoutPlugin extends FlipperPlugin<
|
||||
State,
|
||||
any,
|
||||
PersistedState
|
||||
> {
|
||||
static exportPersistedState = async (
|
||||
callClient:
|
||||
| undefined
|
||||
| ((method: ClientMethodCalls, params?: any) => Promise<any>),
|
||||
persistedState: PersistedState | undefined,
|
||||
store: ReduxState | undefined,
|
||||
_idler?: Idler | undefined,
|
||||
statusUpdate?: (msg: string) => void,
|
||||
supportsMethod?: (method: ClientMethodCalls) => Promise<boolean>,
|
||||
): Promise<PersistedState | undefined> => {
|
||||
if (!store || !callClient) {
|
||||
return persistedState;
|
||||
}
|
||||
statusUpdate && statusUpdate('Fetching Root Node...');
|
||||
// We need not check the if the client supports `getRoot` as if it should and if it doesn't we will get a suppressed notification in Flipper and things will still export, but we will get an error surfaced.
|
||||
const rootElement: Element | null = await callClient('getRoot');
|
||||
const rootAXElement: Element | null =
|
||||
supportsMethod && (await supportsMethod('getAXRoot')) // getAXRoot only relevant for Android
|
||||
? await callClient('getAXRoot')
|
||||
: null;
|
||||
const elements: ElementMap = {};
|
||||
|
||||
if (rootElement) {
|
||||
statusUpdate && statusUpdate('Fetching Child Nodes...');
|
||||
await LayoutPlugin.getAllNodes(
|
||||
rootElement,
|
||||
elements,
|
||||
callClient,
|
||||
'getNodes',
|
||||
supportsMethod,
|
||||
);
|
||||
}
|
||||
const AXelements: ElementMap = {};
|
||||
if (rootAXElement) {
|
||||
statusUpdate && statusUpdate('Fetching Child AX Nodes...');
|
||||
await LayoutPlugin.getAllNodes(
|
||||
rootAXElement,
|
||||
AXelements,
|
||||
callClient,
|
||||
'getAXNodes',
|
||||
supportsMethod,
|
||||
);
|
||||
}
|
||||
statusUpdate && statusUpdate('Finished Fetching Child Nodes...');
|
||||
return {
|
||||
rootElement: rootElement != undefined ? rootElement.id : null,
|
||||
rootAXElement: rootAXElement != undefined ? rootAXElement.id : null,
|
||||
elements,
|
||||
AXelements,
|
||||
};
|
||||
};
|
||||
|
||||
static getAllNodes = async (
|
||||
root: Element,
|
||||
nodeMap: ElementMap,
|
||||
callClient: (method: ClientGetNodesCalls, params?: any) => Promise<any>,
|
||||
method: ClientGetNodesCalls,
|
||||
supportsMethod?: (method: ClientGetNodesCalls) => Promise<boolean>,
|
||||
): Promise<void> => {
|
||||
nodeMap[root.id] = root;
|
||||
if (
|
||||
root.children.length > 0 &&
|
||||
supportsMethod &&
|
||||
(await supportsMethod(method))
|
||||
) {
|
||||
await callClient(method, {ids: root.children}).then(
|
||||
async ({elements}: {elements: Array<Element>}) => {
|
||||
await Promise.all(
|
||||
elements.map(async (elem) => {
|
||||
await LayoutPlugin.getAllNodes(
|
||||
elem,
|
||||
nodeMap,
|
||||
callClient,
|
||||
method,
|
||||
supportsMethod,
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
static serializePersistedState: (
|
||||
persistedState: PersistedState,
|
||||
statusUpdate?: (msg: string) => void,
|
||||
idler?: Idler,
|
||||
) => Promise<string> = (
|
||||
persistedState: PersistedState,
|
||||
statusUpdate?: (msg: string) => void,
|
||||
_idler?: Idler,
|
||||
) => {
|
||||
statusUpdate && statusUpdate('Serializing Inspector Plugin...');
|
||||
return Promise.resolve(JSON.stringify(persistedState));
|
||||
};
|
||||
|
||||
static deserializePersistedState: (
|
||||
serializedString: string,
|
||||
) => PersistedState = (serializedString: string) => {
|
||||
return JSON.parse(serializedString);
|
||||
};
|
||||
|
||||
teardown() {
|
||||
this.state.visualizerWindow?.close();
|
||||
}
|
||||
|
||||
static defaultPersistedState = {
|
||||
rootElement: null,
|
||||
rootAXElement: null,
|
||||
elements: {},
|
||||
AXelements: {},
|
||||
};
|
||||
|
||||
state: State = {
|
||||
init: false,
|
||||
inTargetMode: false,
|
||||
inAXMode: false,
|
||||
inAlignmentMode: false,
|
||||
selectedElement: null,
|
||||
selectedAXElement: null,
|
||||
searchResults: null,
|
||||
visualizerWindow: null,
|
||||
highlightedElement: null,
|
||||
visualizerScreenshot: null,
|
||||
screenDimensions: null,
|
||||
};
|
||||
|
||||
private static isMylesInvoked = false;
|
||||
|
||||
init() {
|
||||
if (!this.props.persistedState) {
|
||||
// If the selected plugin from the previous session was layout, then while importing the flipper export, the redux store doesn't get updated in the first render, due to which the plugin crashes, as it has no persisted state
|
||||
this.props.setPersistedState(this.constructor.defaultPersistedState);
|
||||
}
|
||||
|
||||
if (this.client.isConnected) {
|
||||
// persist searchActive state when moving between plugins to prevent multiple
|
||||
// TouchOverlayViews since we can't edit the view heirarchy in onDisconnect
|
||||
this.client.call('isSearchActive').then(({isSearchActive}) => {
|
||||
this.setState({inTargetMode: isSearchActive});
|
||||
});
|
||||
|
||||
// disable target mode after
|
||||
this.client.subscribe('select', () => {
|
||||
if (this.state.inTargetMode) {
|
||||
this.onToggleTargetMode();
|
||||
}
|
||||
});
|
||||
|
||||
this.client.subscribe('resolvePath', (params: ClassFileParams) => {
|
||||
this.resolvePath(params);
|
||||
});
|
||||
|
||||
this.client.subscribe('openInIDE', (params: OpenFileParams) => {
|
||||
this.openInIDE(params);
|
||||
});
|
||||
}
|
||||
|
||||
// since the first launch of Myles might produce a lag (Myles daemon needs to start)
|
||||
// try to invoke Myles during the first launch of the Layout Plugin
|
||||
if (!LayoutPlugin.isMylesInvoked) {
|
||||
this.invokeMyles();
|
||||
LayoutPlugin.isMylesInvoked = true;
|
||||
}
|
||||
|
||||
if (this.props.isArchivedDevice) {
|
||||
Promise.resolve(this.device)
|
||||
.then((d) => {
|
||||
const handle = (d as ArchivedDevice).getArchivedScreenshotHandle();
|
||||
if (!handle) {
|
||||
throw new Error('No screenshot attached.');
|
||||
}
|
||||
return handle;
|
||||
})
|
||||
.then((handle) => getFlipperMediaCDN(handle, 'Image'))
|
||||
.then((url) => this.setState({visualizerScreenshot: url}))
|
||||
.catch((_) => {
|
||||
// Not all exports have screenshots. This is ok.
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
init: true,
|
||||
selectedElement:
|
||||
typeof this.props.deepLinkPayload === 'string'
|
||||
? this.props.deepLinkPayload
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
resolvePath = async (params: ClassFileParams) => {
|
||||
const paths = await IDEFileResolver.resolveFullPathsFromMyles(
|
||||
params.fileName,
|
||||
params.dirRoot,
|
||||
);
|
||||
const resolvedPath = IDEFileResolver.getBestPath(paths, params.className);
|
||||
if (this.client.isConnected) {
|
||||
this.client.send('setResolvedPath', {
|
||||
className: params.className,
|
||||
resolvedPath: resolvedPath,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
openInIDE = async (params: OpenFileParams) => {
|
||||
let ide: IDEType = Number(IDEType[params.ide]);
|
||||
if (Number.isNaN(ide)) {
|
||||
ide = IDEType.AS; // default value
|
||||
}
|
||||
IDEFileResolver.openInIDE(
|
||||
params.resolvedPath,
|
||||
ide,
|
||||
params.repo,
|
||||
params.lineNumber,
|
||||
);
|
||||
};
|
||||
|
||||
invokeMyles = async () => {
|
||||
await IDEFileResolver.resolveFullPathsFromMyles('.config', 'fbsource');
|
||||
};
|
||||
|
||||
onToggleTargetMode = () => {
|
||||
if (this.client.isConnected) {
|
||||
const inTargetMode = !this.state.inTargetMode;
|
||||
this.setState({inTargetMode});
|
||||
this.client.send('setSearchActive', {active: inTargetMode});
|
||||
}
|
||||
};
|
||||
|
||||
onToggleAXMode = () => {
|
||||
this.setState({inAXMode: !this.state.inAXMode});
|
||||
};
|
||||
|
||||
getClient(): PluginClient {
|
||||
return this.props.isArchivedDevice
|
||||
? new ProxyArchiveClient(this.props.persistedState, (id: string) => {
|
||||
this.setState({highlightedElement: id});
|
||||
})
|
||||
: this.client;
|
||||
}
|
||||
onToggleAlignmentMode = () => {
|
||||
if (this.state.selectedElement) {
|
||||
if (this.client.isConnected) {
|
||||
this.client.send('setHighlighted', {
|
||||
id: this.state.selectedElement,
|
||||
inAlignmentMode: !this.state.inAlignmentMode,
|
||||
});
|
||||
this.setState({inAlignmentMode: !this.state.inAlignmentMode});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onToggleVisualizer = () => {
|
||||
if (this.state.visualizerWindow) {
|
||||
this.state.visualizerWindow.close();
|
||||
} else {
|
||||
const screenDimensions = this.state.screenDimensions;
|
||||
if (!screenDimensions) {
|
||||
return;
|
||||
}
|
||||
const visualizerWindow = window.open(
|
||||
'',
|
||||
'visualizer',
|
||||
`width=${screenDimensions.width},height=${screenDimensions.height}`,
|
||||
);
|
||||
if (!visualizerWindow) {
|
||||
return;
|
||||
}
|
||||
visualizerWindow.onunload = () => {
|
||||
this.setState({visualizerWindow: null});
|
||||
};
|
||||
visualizerWindow.onresize = () => {
|
||||
this.setState({visualizerWindow: visualizerWindow});
|
||||
};
|
||||
visualizerWindow.onload = () => {
|
||||
this.setState({visualizerWindow: visualizerWindow});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
onDataValueChanged = (path: Array<string>, value: any) => {
|
||||
const id = this.state.inAXMode
|
||||
? this.state.selectedAXElement
|
||||
: this.state.selectedElement;
|
||||
this.props.logger.track('usage', 'layoutInspector:setData', {
|
||||
category: path[0],
|
||||
path: Array.from(path).splice(1).join(),
|
||||
...this.realClient.query,
|
||||
});
|
||||
this.client.call('setData', {
|
||||
id,
|
||||
path,
|
||||
value,
|
||||
ax: this.state.inAXMode,
|
||||
});
|
||||
};
|
||||
|
||||
getScreenDimensions(): {width: number; height: number} | null {
|
||||
if (this.state.screenDimensions) {
|
||||
return this.state.screenDimensions;
|
||||
}
|
||||
|
||||
requestIdleCallback(() => {
|
||||
// Walk the layout tree from root node down until a node with width and height is found.
|
||||
// Assume these are the dimensions of the screen.
|
||||
let elementId = this.props.persistedState.rootElement;
|
||||
while (elementId != null) {
|
||||
const element = this.props.persistedState.elements[elementId];
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
if (element.data.View?.width) {
|
||||
break;
|
||||
}
|
||||
elementId = element.children[0];
|
||||
}
|
||||
if (elementId == null) {
|
||||
return null;
|
||||
}
|
||||
const element = this.props.persistedState.elements[elementId];
|
||||
if (
|
||||
element == null ||
|
||||
typeof element.data.View?.width != 'object' ||
|
||||
typeof element.data.View?.height != 'object'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const screenDimensions = {
|
||||
width: element.data.View?.width.value,
|
||||
height: element.data.View?.height.value,
|
||||
};
|
||||
this.setState({screenDimensions});
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const inspectorProps = {
|
||||
client: this.getClient(),
|
||||
inAlignmentMode: this.state.inAlignmentMode,
|
||||
selectedElement: this.state.selectedElement,
|
||||
selectedAXElement: this.state.selectedAXElement,
|
||||
setPersistedState: this.props.setPersistedState,
|
||||
persistedState: this.props.persistedState,
|
||||
searchResults: this.state.searchResults,
|
||||
};
|
||||
|
||||
let element: Element | null = null;
|
||||
const {selectedAXElement, selectedElement, inAXMode} = this.state;
|
||||
if (inAXMode && selectedAXElement) {
|
||||
element = this.props.persistedState.AXelements[selectedAXElement];
|
||||
} else if (selectedElement) {
|
||||
element = this.props.persistedState.elements[selectedElement];
|
||||
}
|
||||
|
||||
const inspector = (
|
||||
<Inspector
|
||||
{...inspectorProps}
|
||||
onSelect={(selectedElement) => this.setState({selectedElement})}
|
||||
showsSidebar={!this.state.inAXMode}
|
||||
/>
|
||||
);
|
||||
|
||||
const axInspector = this.state.inAXMode ? (
|
||||
<Sidebar width={400} backgroundColor="white" position="right">
|
||||
<Inspector
|
||||
{...inspectorProps}
|
||||
onSelect={(selectedAXElement) => this.setState({selectedAXElement})}
|
||||
showsSidebar={true}
|
||||
ax
|
||||
/>
|
||||
</Sidebar>
|
||||
) : null;
|
||||
|
||||
const showAnalyzeYogaPerformanceButton = GK.get('flipper_yogaperformance');
|
||||
|
||||
const screenDimensions = this.getScreenDimensions();
|
||||
|
||||
if (!this.state.init) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Layout.Top>
|
||||
<Toolbar>
|
||||
{!this.props.isArchivedDevice && (
|
||||
<ToolbarIcon
|
||||
onClick={this.onToggleTargetMode}
|
||||
title="Toggle target mode"
|
||||
icon="target"
|
||||
active={this.state.inTargetMode}
|
||||
/>
|
||||
)}
|
||||
{this.realClient.query.os === 'Android' && (
|
||||
<ToolbarIcon
|
||||
onClick={this.onToggleAXMode}
|
||||
title="Toggle to see the accessibility hierarchy"
|
||||
icon="accessibility"
|
||||
active={this.state.inAXMode}
|
||||
/>
|
||||
)}
|
||||
{!this.props.isArchivedDevice && (
|
||||
<ToolbarIcon
|
||||
onClick={this.onToggleAlignmentMode}
|
||||
title="Toggle AlignmentMode to show alignment lines"
|
||||
icon="borders"
|
||||
active={this.state.inAlignmentMode}
|
||||
/>
|
||||
)}
|
||||
{this.props.isArchivedDevice && this.state.visualizerScreenshot && (
|
||||
<ToolbarIcon
|
||||
onClick={this.onToggleVisualizer}
|
||||
title="Toggle visual recreation of layout"
|
||||
icon="mobile"
|
||||
active={!!this.state.visualizerWindow}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Search
|
||||
client={this.getClient()}
|
||||
setPersistedState={this.props.setPersistedState}
|
||||
persistedState={this.props.persistedState}
|
||||
onSearchResults={(searchResults) =>
|
||||
this.setState({searchResults})
|
||||
}
|
||||
inAXMode={this.state.inAXMode}
|
||||
initialQuery={
|
||||
typeof this.props.deepLinkPayload === 'string'
|
||||
? this.props.deepLinkPayload
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</Toolbar>
|
||||
<Layout.Right>
|
||||
{inspector}
|
||||
{axInspector}
|
||||
</Layout.Right>
|
||||
</Layout.Top>
|
||||
|
||||
<DetailSidebar>
|
||||
<InspectorSidebar
|
||||
client={this.getClient()}
|
||||
realClient={this.realClient}
|
||||
element={element}
|
||||
onValueChanged={this.onDataValueChanged}
|
||||
logger={this.props.logger}
|
||||
/>
|
||||
{showAnalyzeYogaPerformanceButton &&
|
||||
element &&
|
||||
element.decoration === 'litho' ? (
|
||||
<Button
|
||||
icon={'share-external'}
|
||||
compact={true}
|
||||
style={{marginTop: 8, marginRight: 12}}
|
||||
onClick={() => {
|
||||
this.props.selectPlugin('YogaPerformance', element!.id);
|
||||
}}>
|
||||
Analyze Yoga Performance
|
||||
</Button>
|
||||
) : null}
|
||||
</DetailSidebar>
|
||||
{this.state.visualizerWindow &&
|
||||
screenDimensions &&
|
||||
(this.state.visualizerScreenshot ? (
|
||||
<VisualizerPortal
|
||||
container={this.state.visualizerWindow.document.body}
|
||||
elements={this.props.persistedState.elements}
|
||||
highlightedElement={this.state.highlightedElement}
|
||||
screenshotURL={this.state.visualizerScreenshot}
|
||||
screenDimensions={screenDimensions}
|
||||
/>
|
||||
) : (
|
||||
'Loading...'
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
25
desktop/plugins/public/layout/package.json
Normal file
25
desktop/plugins/public/layout/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-inspector",
|
||||
"id": "Inspector",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"dependencies": {
|
||||
"deep-equal": "^2.0.5",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^11.2.5"
|
||||
},
|
||||
"title": "Layout",
|
||||
"icon": "target",
|
||||
"bugs": {
|
||||
"email": "oncall+flipper@xmail.facebook.com",
|
||||
"url": "https://fb.workplace.com/groups/flippersupport/"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user