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:
Anton Nikolaev
2021-04-09 05:15:14 -07:00
committed by Facebook GitHub Bot
parent 32bf4c32c2
commit b3274a8450
137 changed files with 2133 additions and 371 deletions

View 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;
}
}

View 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;

View 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;

View 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;

View 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>
);
}
}

View 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',
]),
);
});

View File

@@ -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);
});

View File

@@ -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);
});

View 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...'
))}
</>
);
}
}

View 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/"
}
}