Summary: Split container had a convenient property `scrollable`, that automatically applies a Scroll container to the main content. But I noticed it leads to bad design choices because it is so convenient. So sometimes scrolling would be unnecessarily in two directions because of this. Or, since the scroll container wraps around the whole content, toolbars would scroll out of view. By forcing scrolling to be put explicitly in the component tree, we encourage plugin developers to think about where they actually want to have that scroll, and in which direction. Also added options to use the Container padding properties on ScrollContainer, which is great since we can keep the scrollbars outside the padding, and apply it to the content only, to prevent an accidental mistake where people would put a scroll container in a padded container, that would put the scrollbar inside the padding. Reviewed By: cekkaewnumchai Differential Revision: D24502546 fbshipit-source-id: 524004a1c5f33a185f9b959251b72875dd623cb3
390 lines
11 KiB
TypeScript
390 lines
11 KiB
TypeScript
/**
|
|
* 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 {
|
|
TreeGeneration,
|
|
AddEventPayload,
|
|
UpdateTreeGenerationHierarchyGenerationPayload,
|
|
UpdateTreeGenerationChangesetGenerationPayload,
|
|
UpdateTreeGenerationChangesetApplicationPayload,
|
|
} from './Models';
|
|
|
|
import Tree from './Tree';
|
|
import StackTrace from './StackTrace';
|
|
import EventTable from './EventsTable';
|
|
import DetailsPanel from './DetailsPanel';
|
|
import React, {useState, useMemo} from 'react';
|
|
|
|
import {
|
|
Toolbar,
|
|
Glyph,
|
|
Sidebar,
|
|
FlexBox,
|
|
styled,
|
|
Button,
|
|
Spacer,
|
|
colors,
|
|
DetailSidebar,
|
|
SearchInput,
|
|
SearchBox,
|
|
SearchIcon,
|
|
Layout,
|
|
} from 'flipper';
|
|
|
|
import {PluginClient, createState, usePlugin, useValue} from 'flipper-plugin';
|
|
|
|
const Waiting = styled(FlexBox)({
|
|
width: '100%',
|
|
height: '100%',
|
|
flexGrow: 1,
|
|
background: colors.light02,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
textAlign: 'center',
|
|
});
|
|
|
|
const InfoText = styled.div({
|
|
marginTop: 10,
|
|
marginBottom: 10,
|
|
fontWeight: 500,
|
|
color: colors.light30,
|
|
});
|
|
|
|
const InfoBox = styled.div({
|
|
maxWidth: 400,
|
|
margin: 'auto',
|
|
textAlign: 'center',
|
|
});
|
|
|
|
const TreeContainer = styled.div({
|
|
width: '100%',
|
|
height: '100%',
|
|
overflow: 'hidden',
|
|
});
|
|
|
|
type Events = {
|
|
addEvent: AddEventPayload;
|
|
updateTreeGenerationHierarchyGeneration: UpdateTreeGenerationHierarchyGenerationPayload;
|
|
updateTreeGenerationChangesetApplication: UpdateTreeGenerationChangesetApplicationPayload;
|
|
updateTreeGenerationChangesetGeneration: UpdateTreeGenerationChangesetGenerationPayload;
|
|
};
|
|
|
|
type FocusInfo = {
|
|
generationId: string;
|
|
treeNodeIndexPath?: number[];
|
|
};
|
|
|
|
export function plugin(client: PluginClient<Events, {}>) {
|
|
const generations = createState<{[id: string]: TreeGeneration}>(
|
|
{},
|
|
{persist: 'generations'},
|
|
);
|
|
const focusInfo = createState<FocusInfo | undefined>(undefined);
|
|
const recording = createState<boolean>(true);
|
|
|
|
client.onMessage('addEvent', (data) => {
|
|
if (!recording.get()) {
|
|
return;
|
|
}
|
|
generations.update((draft) => {
|
|
draft[data.id] = {...data, changeSets: []};
|
|
});
|
|
focusInfo.set({
|
|
generationId: focusInfo.get()?.generationId || data.id,
|
|
treeNodeIndexPath: undefined,
|
|
});
|
|
});
|
|
client.onMessage('updateTreeGenerationHierarchyGeneration', (data) => {
|
|
generations.update((draft) => {
|
|
draft[data.id] = {...draft[data.id], ...data};
|
|
});
|
|
});
|
|
function updateTreeGenerationChangeset(
|
|
data:
|
|
| UpdateTreeGenerationChangesetGenerationPayload
|
|
| UpdateTreeGenerationChangesetApplicationPayload,
|
|
) {
|
|
generations.update((draft) => {
|
|
draft[data.tree_generation_id].changeSets.push(data);
|
|
});
|
|
}
|
|
client.onMessage(
|
|
'updateTreeGenerationChangesetApplication',
|
|
updateTreeGenerationChangeset,
|
|
);
|
|
client.onMessage(
|
|
'updateTreeGenerationChangesetGeneration',
|
|
updateTreeGenerationChangeset,
|
|
);
|
|
client.onDeepLink((payload) => {
|
|
if (typeof payload === 'string') {
|
|
handleDeepLinkPayload(payload);
|
|
}
|
|
});
|
|
|
|
function handleDeepLinkPayload(payload: string) {
|
|
// Payload expected to be something like
|
|
// 1.1.FBAnimatingComponent[0].CKFlexboxComponent[2].CKComponent where path components are separated by '.'
|
|
// - The first '1' is the scope root ID.
|
|
// - The numbers in square brackets are the indexes of the following component in the children array
|
|
// of the preceding component. In this example, the last CKComponent is the 2nd child of CKFlexboxComponent.
|
|
const pathComponents = payload.split('.');
|
|
const rootId = pathComponents[0];
|
|
|
|
const generationValues = Object.values(generations.get());
|
|
const mostRecentTreeBuild = generationValues.reverse().find((g) => {
|
|
return g.surface_key == rootId && g.reason == 'Tree Build';
|
|
});
|
|
if (mostRecentTreeBuild) {
|
|
const regex = /\w+\[(\d+)\]/;
|
|
const indexPath = pathComponents.reduce((acc, component) => {
|
|
const match = regex.exec(component);
|
|
if (match) {
|
|
acc.push(+match[1]);
|
|
}
|
|
return acc;
|
|
}, [] as number[]);
|
|
focusInfo.set({
|
|
generationId: mostRecentTreeBuild.id,
|
|
treeNodeIndexPath: indexPath,
|
|
});
|
|
}
|
|
}
|
|
|
|
function setRecording(value: boolean) {
|
|
recording.set(value);
|
|
}
|
|
function clear() {
|
|
generations.set({});
|
|
focusInfo.set(undefined);
|
|
recording.set(true);
|
|
}
|
|
|
|
return {
|
|
generations,
|
|
focusInfo,
|
|
recording,
|
|
setRecording,
|
|
clear,
|
|
};
|
|
}
|
|
|
|
export function Component() {
|
|
const instance = usePlugin(plugin);
|
|
const generations = useValue(instance.generations);
|
|
const focusInfo = useValue(instance.focusInfo);
|
|
const recording = useValue(instance.recording);
|
|
|
|
const [userSelectedGenerationId, setUserSelectedGenerationId] = useState<
|
|
string | undefined
|
|
>();
|
|
const [searchString, setSearchString] = useState<string>('');
|
|
const [focusedChangeSet, setFocusedChangeSet] = useState<
|
|
UpdateTreeGenerationChangesetApplicationPayload | null | undefined
|
|
>(null);
|
|
const [selectedTreeNode, setSelectedTreeNode] = useState<any>();
|
|
|
|
const focusedTreeGeneration: TreeGeneration | null = useMemo(() => {
|
|
const id = userSelectedGenerationId || focusInfo?.generationId;
|
|
if (id === undefined) {
|
|
return null;
|
|
}
|
|
return generations[id];
|
|
}, [userSelectedGenerationId, focusInfo, generations]);
|
|
const filteredGenerations: Array<TreeGeneration> = useMemo(() => {
|
|
const generationValues = Object.values(generations);
|
|
if (searchString.length <= 0) {
|
|
return generationValues;
|
|
}
|
|
|
|
const matchesCurrentSearchString = (s: string): boolean => {
|
|
return s.toLowerCase().includes(searchString.toLowerCase());
|
|
};
|
|
const matchingKeys: Array<string> = generationValues
|
|
.filter((g) => {
|
|
if (g.payload) {
|
|
const componentClassName: string | null | undefined =
|
|
g.payload.component_class_name;
|
|
if (componentClassName) {
|
|
return matchesCurrentSearchString(componentClassName);
|
|
}
|
|
}
|
|
return g.tree?.some((node) => {
|
|
return matchesCurrentSearchString(node.name);
|
|
});
|
|
})
|
|
.map((g) => {
|
|
return g.surface_key;
|
|
});
|
|
|
|
return generationValues.filter((g) => matchingKeys.includes(g.surface_key));
|
|
}, [generations, searchString]);
|
|
|
|
return (
|
|
<Layout.Right>
|
|
<Layout.Top>
|
|
<Toolbar>
|
|
<SearchBox tabIndex={-1}>
|
|
<SearchIcon
|
|
name="magnifying-glass"
|
|
color={colors.macOSTitleBarIcon}
|
|
size={16}
|
|
/>
|
|
<SearchInput
|
|
placeholder={'Search'}
|
|
onChange={(e) => setSearchString(e.target.value)}
|
|
value={searchString}
|
|
/>
|
|
</SearchBox>
|
|
<Spacer />
|
|
{recording ? (
|
|
<Button
|
|
onClick={() => instance.setRecording(false)}
|
|
iconVariant="filled"
|
|
icon="stop-playback">
|
|
Stop
|
|
</Button>
|
|
) : (
|
|
<Button onClick={instance.clear} icon="trash" iconVariant="outline">
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</Toolbar>
|
|
<Layout.Top>
|
|
<Sidebar position="top" minHeight={80} height={80}>
|
|
<EventTable
|
|
generations={filteredGenerations}
|
|
focusedGenerationId={
|
|
userSelectedGenerationId || focusInfo?.generationId
|
|
}
|
|
onClick={(id?: string) => {
|
|
setFocusedChangeSet(null);
|
|
setUserSelectedGenerationId(id);
|
|
setSelectedTreeNode(null);
|
|
}}
|
|
/>
|
|
</Sidebar>
|
|
<Layout.Top>
|
|
<Sidebar position="top" minHeight={400} height={400}>
|
|
<TreeContainer>
|
|
<TreeHierarchy
|
|
generation={focusedTreeGeneration}
|
|
focusedChangeSet={focusedChangeSet}
|
|
setSelectedTreeNode={setSelectedTreeNode}
|
|
selectedNodeIndexPath={focusInfo?.treeNodeIndexPath}
|
|
/>
|
|
</TreeContainer>
|
|
</Sidebar>
|
|
{focusedTreeGeneration && (
|
|
<Layout.ScrollContainer>
|
|
<StackTrace
|
|
data={focusedTreeGeneration.stack_trace}
|
|
skipStackTraceFormat={
|
|
focusedTreeGeneration.skip_stack_trace_format
|
|
}
|
|
/>
|
|
</Layout.ScrollContainer>
|
|
)}
|
|
</Layout.Top>
|
|
</Layout.Top>
|
|
</Layout.Top>
|
|
<DetailSidebar>
|
|
<DetailsPanel
|
|
eventUserInfo={focusedTreeGeneration?.payload}
|
|
changeSets={focusedTreeGeneration?.changeSets}
|
|
onFocusChangeSet={(
|
|
focusedChangeSet:
|
|
| UpdateTreeGenerationChangesetApplicationPayload
|
|
| null
|
|
| undefined,
|
|
) => {
|
|
setFocusedChangeSet(focusedChangeSet);
|
|
setSelectedTreeNode(null);
|
|
}}
|
|
focusedChangeSet={focusedChangeSet}
|
|
selectedNodeInfo={selectedTreeNode}
|
|
/>
|
|
</DetailSidebar>
|
|
</Layout.Right>
|
|
);
|
|
}
|
|
|
|
function TreeHierarchy({
|
|
generation,
|
|
focusedChangeSet,
|
|
setSelectedTreeNode,
|
|
selectedNodeIndexPath,
|
|
}: {
|
|
generation: TreeGeneration | null;
|
|
focusedChangeSet:
|
|
| UpdateTreeGenerationChangesetApplicationPayload
|
|
| null
|
|
| undefined;
|
|
setSelectedTreeNode: (node: any) => void;
|
|
selectedNodeIndexPath?: number[];
|
|
}) {
|
|
const onNodeClicked = useMemo(
|
|
() => (targetNode: any) => {
|
|
if (targetNode.attributes.isSection) {
|
|
const sectionData: any = {};
|
|
sectionData.global_key = targetNode.attributes.identifier;
|
|
setSelectedTreeNode({sectionData});
|
|
return;
|
|
}
|
|
|
|
let dataModel;
|
|
// Not all models can be parsed.
|
|
if (targetNode.attributes.isDataModel) {
|
|
try {
|
|
dataModel = JSON.parse(targetNode.attributes.identifier);
|
|
} catch (e) {
|
|
dataModel = targetNode.attributes.identifier;
|
|
}
|
|
}
|
|
|
|
setSelectedTreeNode({dataModel});
|
|
},
|
|
[setSelectedTreeNode],
|
|
);
|
|
|
|
if (generation && generation.tree && generation.tree.length > 0) {
|
|
// Display component tree hierarchy, if any
|
|
return (
|
|
<Tree
|
|
data={generation.tree}
|
|
nodeClickHandler={onNodeClicked}
|
|
selectedNodeIndexPath={selectedNodeIndexPath}
|
|
/>
|
|
);
|
|
} else if (focusedChangeSet && focusedChangeSet.section_component_hierarchy) {
|
|
// Display section component hierarchy for specific changeset
|
|
return (
|
|
<Tree
|
|
data={focusedChangeSet.section_component_hierarchy}
|
|
nodeClickHandler={onNodeClicked}
|
|
selectedNodeIndexPath={selectedNodeIndexPath}
|
|
/>
|
|
);
|
|
} else {
|
|
return (
|
|
<Waiting>
|
|
<InfoBox>
|
|
<Glyph
|
|
name="face-unhappy"
|
|
variant="outline"
|
|
size={24}
|
|
color={colors.light30}
|
|
/>
|
|
<InfoText>No data available...</InfoText>
|
|
</InfoBox>
|
|
</Waiting>
|
|
);
|
|
}
|
|
}
|