Move plugins to "sonar/desktop/plugins"
Summary: Plugins moved from "sonar/desktop/src/plugins" to "sonar/desktop/plugins". Fixed all the paths after moving. New "desktop" folder structure: - `src` - Flipper desktop app JS code executing in Electron Renderer (Chrome) process. - `static` - Flipper desktop app JS code executing in Electron Main (Node.js) process. - `plugins` - Flipper desktop JS plugins. - `pkg` - Flipper packaging lib and CLI tool. - `doctor` - Flipper diagnostics lib and CLI tool. - `scripts` - Build scripts for Flipper desktop app. - `headless` - Headless version of Flipper desktop app. - `headless-tests` - Integration tests running agains Flipper headless version. Reviewed By: mweststrate Differential Revision: D20344186 fbshipit-source-id: d020da970b2ea1e001f9061a8782bfeb54e31ba0
This commit is contained in:
committed by
Facebook GitHub Bot
parent
beb5c85e69
commit
10d990c32c
78
desktop/plugins/fresco/ImagePool.tsx
Normal file
78
desktop/plugins/fresco/ImagePool.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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 {ImageId, ImageData} from './api';
|
||||
|
||||
export type ImagesMap = {[imageId in ImageId]: ImageData};
|
||||
|
||||
const maxInflightRequests = 10;
|
||||
|
||||
export default class ImagePool {
|
||||
cache: ImagesMap = {};
|
||||
requested: {[imageId in ImageId]: boolean} = {};
|
||||
queued: Array<ImageId> = [];
|
||||
inFlightRequests: number = 0;
|
||||
fetchImage: (imageId: ImageId) => void;
|
||||
updateNotificationScheduled: boolean = false;
|
||||
onPoolUpdated: (images: ImagesMap) => void;
|
||||
|
||||
constructor(
|
||||
fetchImage: (imageId: ImageId) => void,
|
||||
onPoolUpdated: (images: ImagesMap) => void,
|
||||
) {
|
||||
this.fetchImage = fetchImage;
|
||||
this.onPoolUpdated = onPoolUpdated;
|
||||
}
|
||||
|
||||
getImages(): ImagesMap {
|
||||
return {...this.cache};
|
||||
}
|
||||
|
||||
fetchImages(ids: Array<string>) {
|
||||
for (const id of ids) {
|
||||
if (!this.cache[id] && !this.requested[id]) {
|
||||
this.requested[id] = true;
|
||||
|
||||
if (this.inFlightRequests < maxInflightRequests) {
|
||||
this.inFlightRequests++;
|
||||
this.fetchImage(id);
|
||||
} else {
|
||||
this.queued.unshift(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cache = {};
|
||||
this.requested = {};
|
||||
}
|
||||
|
||||
_fetchCompleted(image: ImageData): void {
|
||||
this.cache[image.imageId] = image;
|
||||
delete this.requested[image.imageId];
|
||||
|
||||
if (this.queued.length > 0) {
|
||||
const popped = this.queued.pop() as string;
|
||||
this.fetchImage(popped);
|
||||
} else {
|
||||
this.inFlightRequests--;
|
||||
}
|
||||
|
||||
if (!this.updateNotificationScheduled) {
|
||||
this.updateNotificationScheduled = true;
|
||||
window.setTimeout(this._notify, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
_notify = () => {
|
||||
this.updateNotificationScheduled = false;
|
||||
this.onPoolUpdated(this.getImages());
|
||||
};
|
||||
}
|
||||
511
desktop/plugins/fresco/ImagesCacheOverview.tsx
Normal file
511
desktop/plugins/fresco/ImagesCacheOverview.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* 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 {CacheInfo, ImageId, ImageData, ImagesList} from './api';
|
||||
import {ImageEventWithId} from './index';
|
||||
|
||||
import {
|
||||
Toolbar,
|
||||
Button,
|
||||
Spacer,
|
||||
colors,
|
||||
FlexBox,
|
||||
FlexRow,
|
||||
FlexColumn,
|
||||
LoadingIndicator,
|
||||
styled,
|
||||
Select,
|
||||
ToggleButton,
|
||||
Text,
|
||||
} from 'flipper';
|
||||
import MultipleSelect from './MultipleSelect';
|
||||
import {ImagesMap} from './ImagePool';
|
||||
import {clipboard} from 'electron';
|
||||
import React, {ChangeEvent, KeyboardEvent, PureComponent} from 'react';
|
||||
|
||||
function formatMB(bytes: number) {
|
||||
return Math.floor(bytes / (1024 * 1024)) + 'MB';
|
||||
}
|
||||
|
||||
function formatKB(bytes: number) {
|
||||
return Math.floor(bytes / 1024) + 'KB';
|
||||
}
|
||||
|
||||
type ToggleProps = {
|
||||
label: string;
|
||||
onClick?: (newValue: boolean) => void;
|
||||
toggled: boolean;
|
||||
};
|
||||
|
||||
const ToolbarToggleButton = styled(ToggleButton)(_props => ({
|
||||
alignSelf: 'center',
|
||||
marginRight: 4,
|
||||
minWidth: 30,
|
||||
}));
|
||||
|
||||
const ToggleLabel = styled(Text)(_props => ({
|
||||
whiteSpace: 'nowrap',
|
||||
}));
|
||||
|
||||
function Toggle(props: ToggleProps) {
|
||||
return (
|
||||
<>
|
||||
<ToolbarToggleButton
|
||||
onClick={() => {
|
||||
props.onClick && props.onClick(!props.toggled);
|
||||
}}
|
||||
toggled={props.toggled}
|
||||
/>
|
||||
<ToggleLabel>{props.label}</ToggleLabel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ImagesCacheOverviewProps = {
|
||||
onColdStartChange: (checked: boolean) => void;
|
||||
coldStartFilter: boolean;
|
||||
allSurfacesOption: string;
|
||||
surfaceOptions: Set<string>;
|
||||
selectedSurfaces: Set<string>;
|
||||
onChangeSurface: (key: Set<string>) => void;
|
||||
images: ImagesList;
|
||||
onClear: (type: string) => void;
|
||||
onTrimMemory: () => void;
|
||||
onRefresh: () => void;
|
||||
onEnableDebugOverlay: (enabled: boolean) => void;
|
||||
isDebugOverlayEnabled: boolean;
|
||||
onEnableAutoRefresh: (enabled: boolean) => void;
|
||||
isAutoRefreshEnabled: boolean;
|
||||
onImageSelected: (selectedImage: ImageId) => void;
|
||||
imagesMap: ImagesMap;
|
||||
events: Array<ImageEventWithId>;
|
||||
onTrackLeaks: (enabled: boolean) => void;
|
||||
isLeakTrackingEnabled: boolean;
|
||||
};
|
||||
|
||||
type ImagesCacheOverviewState = {
|
||||
selectedImage: ImageId | null;
|
||||
size: number;
|
||||
};
|
||||
|
||||
const StyledSelect = styled(Select)(props => ({
|
||||
marginLeft: 6,
|
||||
marginRight: 6,
|
||||
height: '100%',
|
||||
maxWidth: 164,
|
||||
}));
|
||||
|
||||
export default class ImagesCacheOverview extends PureComponent<
|
||||
ImagesCacheOverviewProps,
|
||||
ImagesCacheOverviewState
|
||||
> {
|
||||
state = {
|
||||
selectedImage: null,
|
||||
size: 150,
|
||||
};
|
||||
|
||||
static Container = styled(FlexColumn)({
|
||||
backgroundColor: colors.white,
|
||||
});
|
||||
|
||||
static Content = styled(FlexColumn)({
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
static Empty = styled(FlexBox)({
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
onImageSelected = (selectedImage: ImageId) => {
|
||||
this.setState({selectedImage});
|
||||
this.props.onImageSelected(selectedImage);
|
||||
};
|
||||
|
||||
onKeyDown = (e: KeyboardEvent) => {
|
||||
const selectedImage = this.state.selectedImage;
|
||||
const imagesMap = this.props.imagesMap;
|
||||
|
||||
if (selectedImage) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
||||
clipboard.writeText(String(imagesMap[selectedImage]));
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onEnableDebugOverlayToggled = () => {
|
||||
this.props.onEnableDebugOverlay(!this.props.isDebugOverlayEnabled);
|
||||
};
|
||||
|
||||
onEnableAutoRefreshToggled = () => {
|
||||
this.props.onEnableAutoRefresh(!this.props.isAutoRefreshEnabled);
|
||||
};
|
||||
|
||||
onChangeSize = (e: ChangeEvent<HTMLInputElement>) =>
|
||||
this.setState({size: parseInt(e.target.value, 10)});
|
||||
|
||||
onSurfaceOptionsChange = (selectedItem: string, checked: boolean) => {
|
||||
const {allSurfacesOption, surfaceOptions} = this.props;
|
||||
const selectedSurfaces = new Set([...this.props.selectedSurfaces]);
|
||||
|
||||
if (checked && selectedItem === allSurfacesOption) {
|
||||
this.props.onChangeSurface(surfaceOptions);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checked && selectedSurfaces.size === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedItem !== allSurfacesOption) {
|
||||
selectedSurfaces.delete(allSurfacesOption);
|
||||
|
||||
if (checked) {
|
||||
selectedSurfaces.add(selectedItem);
|
||||
} else {
|
||||
selectedSurfaces.delete(selectedItem);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
surfaceOptions.size - selectedSurfaces.size === 1 &&
|
||||
!selectedSurfaces.has(allSurfacesOption)
|
||||
) {
|
||||
selectedSurfaces.add(allSurfacesOption);
|
||||
}
|
||||
|
||||
this.props.onChangeSurface(selectedSurfaces);
|
||||
};
|
||||
|
||||
render() {
|
||||
const hasImages =
|
||||
this.props.images.reduce(
|
||||
(c, cacheInfo) => c + cacheInfo.imageIds.length,
|
||||
0,
|
||||
) > 0;
|
||||
|
||||
return (
|
||||
<ImagesCacheOverview.Container
|
||||
grow={true}
|
||||
onKeyDown={this.onKeyDown}
|
||||
tabIndex={0}>
|
||||
<Toolbar position="top">
|
||||
<Button icon="trash" onClick={this.props.onTrimMemory}>
|
||||
Trim Memory
|
||||
</Button>
|
||||
<Button onClick={this.props.onRefresh}>Refresh</Button>
|
||||
<MultipleSelect
|
||||
selected={this.props.selectedSurfaces}
|
||||
options={this.props.surfaceOptions}
|
||||
onChange={this.onSurfaceOptionsChange}
|
||||
label="Surfaces"
|
||||
/>
|
||||
<Toggle
|
||||
onClick={this.onEnableAutoRefreshToggled}
|
||||
toggled={this.props.isAutoRefreshEnabled}
|
||||
label="Auto Refresh"
|
||||
/>
|
||||
<Toggle
|
||||
onClick={this.onEnableDebugOverlayToggled}
|
||||
toggled={this.props.isDebugOverlayEnabled}
|
||||
label="Show Debug Overlay"
|
||||
/>
|
||||
<Toggle
|
||||
toggled={this.props.coldStartFilter}
|
||||
onClick={this.props.onColdStartChange}
|
||||
label="Show Cold Start Images"
|
||||
/>
|
||||
<Toggle
|
||||
toggled={this.props.isLeakTrackingEnabled}
|
||||
onClick={this.props.onTrackLeaks}
|
||||
label="Track Leaks"
|
||||
/>
|
||||
<Spacer />
|
||||
<input
|
||||
type="range"
|
||||
onChange={this.onChangeSize}
|
||||
min={50}
|
||||
max={150}
|
||||
value={this.state.size}
|
||||
/>
|
||||
</Toolbar>
|
||||
{!hasImages ? (
|
||||
<ImagesCacheOverview.Empty>
|
||||
<LoadingIndicator size={50} />
|
||||
</ImagesCacheOverview.Empty>
|
||||
) : (
|
||||
<ImagesCacheOverview.Content>
|
||||
{this.props.images.map((data: CacheInfo, index: number) => {
|
||||
const maxSize = data.maxSizeBytes;
|
||||
const subtitle = maxSize
|
||||
? formatMB(data.sizeBytes) + ' / ' + formatMB(maxSize)
|
||||
: formatMB(data.sizeBytes);
|
||||
const onClear =
|
||||
data.clearKey !== undefined
|
||||
? () => this.props.onClear(data.clearKey as string)
|
||||
: undefined;
|
||||
return (
|
||||
<ImageGrid
|
||||
key={index}
|
||||
title={data.cacheType}
|
||||
subtitle={subtitle}
|
||||
images={data.imageIds}
|
||||
onImageSelected={this.onImageSelected}
|
||||
selectedImage={this.state.selectedImage}
|
||||
imagesMap={this.props.imagesMap}
|
||||
size={this.state.size}
|
||||
events={this.props.events}
|
||||
onClear={onClear}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ImagesCacheOverview.Content>
|
||||
)}
|
||||
</ImagesCacheOverview.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImageGrid extends PureComponent<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
images: Array<ImageId>;
|
||||
selectedImage: ImageId | null;
|
||||
onImageSelected: (image: ImageId) => void;
|
||||
onClear: (() => void) | undefined;
|
||||
imagesMap: ImagesMap;
|
||||
size: number;
|
||||
events: Array<ImageEventWithId>;
|
||||
}> {
|
||||
static Content = styled.div({
|
||||
paddingLeft: 15,
|
||||
});
|
||||
|
||||
render() {
|
||||
const {images, onImageSelected, selectedImage} = this.props;
|
||||
|
||||
if (images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
<ImageGridHeader
|
||||
key="header"
|
||||
title={this.props.title}
|
||||
subtitle={this.props.subtitle}
|
||||
onClear={this.props.onClear}
|
||||
/>,
|
||||
<ImageGrid.Content key="content">
|
||||
{images.map(imageId => (
|
||||
<ImageItem
|
||||
imageId={imageId}
|
||||
image={this.props.imagesMap[imageId]}
|
||||
key={imageId}
|
||||
selected={selectedImage != null && selectedImage === imageId}
|
||||
onSelected={onImageSelected}
|
||||
size={this.props.size}
|
||||
numberOfRequests={
|
||||
this.props.events.filter(e => e.imageIds.includes(imageId)).length
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ImageGrid.Content>,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class ImageGridHeader extends PureComponent<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onClear: (() => void) | undefined;
|
||||
}> {
|
||||
static Container = styled(FlexRow)({
|
||||
color: colors.dark70,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
marginLeft: 15,
|
||||
marginRight: 15,
|
||||
marginBottom: 15,
|
||||
borderBottom: `1px solid ${colors.light10}`,
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.white,
|
||||
zIndex: 3,
|
||||
});
|
||||
|
||||
static Heading = styled.span({
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
static Subtitle = styled.span({
|
||||
fontSize: 22,
|
||||
fontWeight: 300,
|
||||
marginLeft: 15,
|
||||
});
|
||||
|
||||
static ClearButton = styled(Button)({
|
||||
alignSelf: 'center',
|
||||
height: 30,
|
||||
marginLeft: 'auto',
|
||||
width: 100,
|
||||
});
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ImageGridHeader.Container>
|
||||
<ImageGridHeader.Heading>{this.props.title}</ImageGridHeader.Heading>
|
||||
<ImageGridHeader.Subtitle>
|
||||
{this.props.subtitle}
|
||||
</ImageGridHeader.Subtitle>
|
||||
<Spacer />
|
||||
{this.props.onClear ? (
|
||||
<ImageGridHeader.ClearButton onClick={this.props.onClear}>
|
||||
Clear Cache
|
||||
</ImageGridHeader.ClearButton>
|
||||
) : null}
|
||||
</ImageGridHeader.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImageItem extends PureComponent<{
|
||||
imageId: ImageId;
|
||||
image: ImageData;
|
||||
selected: boolean;
|
||||
onSelected: (image: ImageId) => void;
|
||||
size: number;
|
||||
numberOfRequests: number;
|
||||
}> {
|
||||
static Container = styled(FlexBox)<{size: number}>(props => ({
|
||||
float: 'left',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
height: props.size,
|
||||
width: props.size,
|
||||
borderRadius: 4,
|
||||
marginRight: 15,
|
||||
marginBottom: 15,
|
||||
backgroundColor: colors.light02,
|
||||
}));
|
||||
|
||||
static Image = styled.img({
|
||||
borderRadius: 4,
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
objectFit: 'contain',
|
||||
});
|
||||
|
||||
static Loading = styled.span({
|
||||
padding: '0 0',
|
||||
});
|
||||
|
||||
static SelectedHighlight = styled.div<{selected: boolean}>(props => ({
|
||||
borderColor: colors.highlight,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: props.selected ? 3 : 0,
|
||||
borderRadius: 4,
|
||||
boxShadow: props.selected ? `inset 0 0 0 1px ${colors.white}` : 'none',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
}));
|
||||
|
||||
static HoverOverlay = styled(FlexColumn)<{selected: boolean; size: number}>(
|
||||
props => ({
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.whiteAlpha80,
|
||||
bottom: props.selected ? 4 : 0,
|
||||
fontSize: props.size > 100 ? 16 : 11,
|
||||
justifyContent: 'center',
|
||||
left: props.selected ? 4 : 0,
|
||||
opacity: 0,
|
||||
position: 'absolute',
|
||||
right: props.selected ? 4 : 0,
|
||||
top: props.selected ? 4 : 0,
|
||||
overflow: 'hidden',
|
||||
transition: '.1s opacity',
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
static MemoryLabel = styled.span({
|
||||
fontWeight: 600,
|
||||
marginBottom: 6,
|
||||
});
|
||||
|
||||
static SizeLabel = styled.span({
|
||||
fontWeight: 300,
|
||||
});
|
||||
|
||||
static Events = styled.div({
|
||||
position: 'absolute',
|
||||
top: -5,
|
||||
right: -5,
|
||||
color: colors.white,
|
||||
backgroundColor: colors.highlight,
|
||||
fontWeight: 600,
|
||||
borderRadius: 10,
|
||||
fontSize: '0.85em',
|
||||
zIndex: 2,
|
||||
lineHeight: '20px',
|
||||
width: 20,
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
static defaultProps = {
|
||||
size: 150,
|
||||
};
|
||||
|
||||
onClick = () => {
|
||||
this.props.onSelected(this.props.imageId);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {image, selected, size, numberOfRequests} = this.props;
|
||||
|
||||
return (
|
||||
<ImageItem.Container onClick={this.onClick} size={size}>
|
||||
{numberOfRequests > 0 && image != null && (
|
||||
<ImageItem.Events>{numberOfRequests}</ImageItem.Events>
|
||||
)}
|
||||
{image != null ? (
|
||||
<ImageItem.Image src={image.data} />
|
||||
) : (
|
||||
<LoadingIndicator size={25} />
|
||||
)}
|
||||
<ImageItem.SelectedHighlight selected={selected} />
|
||||
{image != null && (
|
||||
<ImageItem.HoverOverlay selected={selected} size={size}>
|
||||
<ImageItem.MemoryLabel>
|
||||
{formatKB(image.sizeBytes)}
|
||||
</ImageItem.MemoryLabel>
|
||||
<ImageItem.SizeLabel>
|
||||
{image.width}×{image.height}
|
||||
</ImageItem.SizeLabel>
|
||||
</ImageItem.HoverOverlay>
|
||||
)}
|
||||
</ImageItem.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
193
desktop/plugins/fresco/ImagesSidebar.tsx
Normal file
193
desktop/plugins/fresco/ImagesSidebar.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 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 {ImageData} from './api';
|
||||
import {ImageEventWithId} from './index';
|
||||
import {
|
||||
Component,
|
||||
ContextMenu,
|
||||
DataDescription,
|
||||
Text,
|
||||
Panel,
|
||||
ManagedDataInspector,
|
||||
FlexColumn,
|
||||
FlexRow,
|
||||
colors,
|
||||
styled,
|
||||
} from 'flipper';
|
||||
import React from 'react';
|
||||
import {clipboard, MenuItemConstructorOptions} from 'electron';
|
||||
|
||||
type ImagesSidebarProps = {
|
||||
image: ImageData;
|
||||
events: Array<ImageEventWithId>;
|
||||
};
|
||||
|
||||
type ImagesSidebarState = {};
|
||||
|
||||
const DataDescriptionKey = styled.span({
|
||||
color: colors.grapeDark1,
|
||||
});
|
||||
|
||||
const WordBreakFlexColumn = styled(FlexColumn)({
|
||||
wordBreak: 'break-all',
|
||||
});
|
||||
|
||||
export default class ImagesSidebar extends Component<
|
||||
ImagesSidebarProps,
|
||||
ImagesSidebarState
|
||||
> {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderUri()}
|
||||
{this.props.events.map(e => (
|
||||
<EventDetails key={e.eventId} event={e} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderUri() {
|
||||
if (!this.props.image) {
|
||||
return null;
|
||||
}
|
||||
if (!this.props.image.uri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contextMenuItems: MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: 'Copy URI',
|
||||
click: () => clipboard.writeText(this.props.image.uri!),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Panel heading="Sources" floating={false}>
|
||||
<FlexRow>
|
||||
<FlexColumn>
|
||||
<DataDescriptionKey>URI</DataDescriptionKey>
|
||||
</FlexColumn>
|
||||
<FlexColumn>
|
||||
<span key="sep">: </span>
|
||||
</FlexColumn>
|
||||
<WordBreakFlexColumn>
|
||||
<ContextMenu component="span" items={contextMenuItems}>
|
||||
<DataDescription
|
||||
type="string"
|
||||
value={this.props.image.uri}
|
||||
setValue={null}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</WordBreakFlexColumn>
|
||||
</FlexRow>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EventDetails extends Component<{
|
||||
event: ImageEventWithId;
|
||||
}> {
|
||||
render() {
|
||||
const {event} = this.props;
|
||||
|
||||
return (
|
||||
<Panel
|
||||
heading={<RequestHeader event={event} />}
|
||||
floating={false}
|
||||
padded={true}>
|
||||
<p>
|
||||
<DataDescriptionKey>Attribution</DataDescriptionKey>
|
||||
<span key="sep">: </span>
|
||||
<ManagedDataInspector data={event.attribution} />
|
||||
</p>
|
||||
<p>
|
||||
<DataDescriptionKey>Time start</DataDescriptionKey>
|
||||
<span key="sep">: </span>
|
||||
<DataDescription
|
||||
type="number"
|
||||
value={event.startTime}
|
||||
setValue={function(path: Array<string>, val: any) {}}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<DataDescriptionKey>Time end</DataDescriptionKey>
|
||||
<span key="sep">: </span>
|
||||
<DataDescription
|
||||
type="number"
|
||||
value={event.endTime}
|
||||
setValue={function(path: Array<string>, val: any) {}}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<DataDescriptionKey>Source</DataDescriptionKey>
|
||||
<span key="sep">: </span>
|
||||
<DataDescription
|
||||
type="string"
|
||||
value={event.source}
|
||||
setValue={function(path: Array<string>, val: any) {}}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<DataDescriptionKey>Requested on cold start</DataDescriptionKey>
|
||||
<span key="sep">: </span>
|
||||
<DataDescription
|
||||
type="boolean"
|
||||
value={event.coldStart}
|
||||
setValue={null}
|
||||
/>
|
||||
</p>
|
||||
{this.renderViewportData()}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
renderViewportData() {
|
||||
const viewport = this.props.event.viewport;
|
||||
if (!viewport) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<p>
|
||||
<DataDescriptionKey>Viewport</DataDescriptionKey>
|
||||
<span key="sep">: </span>
|
||||
<DataDescription
|
||||
type="string"
|
||||
value={viewport.width + 'x' + viewport.height}
|
||||
setValue={function(path: Array<string>, val: any) {}}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
// TODO (t31947746): grey box time, n-th scan time
|
||||
}
|
||||
}
|
||||
|
||||
class RequestHeader extends Component<{
|
||||
event: ImageEventWithId;
|
||||
}> {
|
||||
dateString = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return `${date.toTimeString().split(' ')[0]}.${(
|
||||
'000' + date.getMilliseconds()
|
||||
).substr(-3)}`;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {event} = this.props;
|
||||
const durationMs = event.endTime - event.startTime;
|
||||
return (
|
||||
<Text>
|
||||
{event.viewport ? 'Request' : 'Prefetch'} at{' '}
|
||||
{this.dateString(event.startTime)} ({durationMs}ms)
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
111
desktop/plugins/fresco/MultipleSelect.tsx
Normal file
111
desktop/plugins/fresco/MultipleSelect.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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 {Block, Button, colors, FlexColumn, styled, Glyph} from 'flipper';
|
||||
import React, {ChangeEvent, Component} from 'react';
|
||||
|
||||
const Container = styled(Block)({
|
||||
position: 'relative',
|
||||
marginLeft: '10px',
|
||||
});
|
||||
|
||||
const List = styled(FlexColumn)<{visibleList: boolean}>(props => ({
|
||||
display: props.visibleList ? 'flex' : 'none',
|
||||
position: 'absolute',
|
||||
top: '32px',
|
||||
left: 0,
|
||||
zIndex: 4,
|
||||
width: 'auto',
|
||||
minWidth: '200px',
|
||||
backgroundColor: colors.white,
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: colors.macOSTitleBarButtonBorderBottom,
|
||||
borderRadius: 4,
|
||||
}));
|
||||
|
||||
const ListItem = styled.label({
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
color: colors.light50,
|
||||
fontSize: '11px',
|
||||
padding: '0 5px',
|
||||
'&:hover': {
|
||||
backgroundColor: colors.macOSTitleBarButtonBackgroundActiveHighlight,
|
||||
},
|
||||
});
|
||||
|
||||
const Checkbox = styled.input({
|
||||
display: 'inline-block',
|
||||
marginRight: 5,
|
||||
verticalAlign: 'middle',
|
||||
});
|
||||
|
||||
const StyledGlyph = styled(Glyph)({
|
||||
marginLeft: '4px',
|
||||
});
|
||||
|
||||
type State = {
|
||||
visibleList: boolean;
|
||||
};
|
||||
|
||||
export default class MultipleSelect extends Component<
|
||||
{
|
||||
selected: Set<string>;
|
||||
|
||||
options: Set<string>;
|
||||
|
||||
onChange: (selectedItem: string, checked: boolean) => void;
|
||||
|
||||
label: string;
|
||||
},
|
||||
State
|
||||
> {
|
||||
state = {
|
||||
visibleList: false,
|
||||
};
|
||||
|
||||
handleOnChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const {
|
||||
target: {value, checked},
|
||||
} = event;
|
||||
this.props.onChange(value, checked);
|
||||
};
|
||||
|
||||
toggleList = () => this.setState({visibleList: !this.state.visibleList});
|
||||
|
||||
render() {
|
||||
const {selected, label, options} = this.props;
|
||||
const {visibleList} = this.state;
|
||||
const icon = visibleList ? 'chevron-up' : 'chevron-down';
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Button onClick={this.toggleList}>
|
||||
{label} <StyledGlyph name={icon} />
|
||||
</Button>
|
||||
<List visibleList={visibleList}>
|
||||
{Array.from(options).map((option, index) => (
|
||||
<ListItem key={index}>
|
||||
<Checkbox
|
||||
onChange={this.handleOnChange}
|
||||
checked={selected.has(option)}
|
||||
value={option}
|
||||
type="checkbox"
|
||||
/>
|
||||
{option}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`notifications for leaks 1`] = `
|
||||
<React.Fragment>
|
||||
<Styled(div)>
|
||||
CloseableReference leaked for
|
||||
|
||||
<Text
|
||||
code={true}
|
||||
>
|
||||
com.facebook.imagepipeline.memory.NativeMemoryChunk
|
||||
</Text>
|
||||
(identity hashcode:
|
||||
deadbeef
|
||||
).
|
||||
</Styled(div)>
|
||||
<Styled(div)>
|
||||
<Text
|
||||
bold={true}
|
||||
>
|
||||
Stacktrace:
|
||||
</Text>
|
||||
</Styled(div)>
|
||||
<Styled(div)>
|
||||
<Text
|
||||
code={true}
|
||||
>
|
||||
<unavailable>
|
||||
</Text>
|
||||
</Styled(div)>
|
||||
</React.Fragment>
|
||||
`;
|
||||
324
desktop/plugins/fresco/__tests__/index.node.tsx
Normal file
324
desktop/plugins/fresco/__tests__/index.node.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 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 FrescoPlugin from '../index';
|
||||
import {PersistedState, ImageEventWithId} from '../index';
|
||||
import {AndroidCloseableReferenceLeakEvent} from '../api';
|
||||
import {MetricType, Notification} from 'flipper';
|
||||
import {ImagesMap} from '../ImagePool';
|
||||
|
||||
type ScanDisplayTime = {[scan_number: number]: number};
|
||||
|
||||
function mockPersistedState(
|
||||
imageSizes: Array<{
|
||||
width: number;
|
||||
height: number;
|
||||
}> = [],
|
||||
viewport: {
|
||||
width: number;
|
||||
height: number;
|
||||
} = {width: 150, height: 150},
|
||||
): PersistedState {
|
||||
const scanDisplayTime: ScanDisplayTime = {};
|
||||
scanDisplayTime[1] = 3;
|
||||
const events: Array<ImageEventWithId> = [
|
||||
{
|
||||
imageIds: [...Array(imageSizes.length).keys()].map(String),
|
||||
eventId: 0,
|
||||
attribution: [],
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
source: 'source',
|
||||
coldStart: true,
|
||||
viewport: {...viewport, scanDisplayTime},
|
||||
},
|
||||
];
|
||||
|
||||
const imagesMap = imageSizes.reduce((acc, val, index) => {
|
||||
acc[index] = {
|
||||
imageId: String(index),
|
||||
width: val.width,
|
||||
height: val.height,
|
||||
sizeBytes: 10,
|
||||
data: 'undefined',
|
||||
};
|
||||
return acc;
|
||||
}, {} as ImagesMap);
|
||||
|
||||
return {
|
||||
surfaceList: new Set(),
|
||||
images: [],
|
||||
events,
|
||||
imagesMap,
|
||||
closeableReferenceLeaks: [],
|
||||
isLeakTrackingEnabled: false,
|
||||
nextEventId: 0,
|
||||
};
|
||||
}
|
||||
|
||||
test('the metric reducer for the input having regression', () => {
|
||||
const persistedState = mockPersistedState(
|
||||
[
|
||||
{
|
||||
width: 150,
|
||||
height: 150,
|
||||
},
|
||||
{
|
||||
width: 150,
|
||||
height: 150,
|
||||
},
|
||||
{
|
||||
width: 150,
|
||||
height: 150,
|
||||
},
|
||||
],
|
||||
{
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
);
|
||||
expect(FrescoPlugin.metricsReducer).toBeDefined();
|
||||
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
|
||||
const metrics = FrescoPlugin.metricsReducer(persistedState);
|
||||
return expect(metrics).resolves.toMatchObject({
|
||||
WASTED_BYTES: 37500,
|
||||
});
|
||||
});
|
||||
|
||||
test('the metric reducer for the input having no regression', () => {
|
||||
const persistedState = mockPersistedState(
|
||||
[
|
||||
{
|
||||
width: 50,
|
||||
height: 10,
|
||||
},
|
||||
{
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
{
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
],
|
||||
{
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
);
|
||||
const metricsReducer = FrescoPlugin.metricsReducer;
|
||||
expect(metricsReducer).toBeDefined();
|
||||
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
|
||||
const metrics = metricsReducer(persistedState);
|
||||
return expect(metrics).resolves.toMatchObject({
|
||||
WASTED_BYTES: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('the metric reducer for the default persisted state', () => {
|
||||
const metricsReducer = FrescoPlugin.metricsReducer;
|
||||
expect(metricsReducer).toBeDefined();
|
||||
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
|
||||
const metrics = metricsReducer(FrescoPlugin.defaultPersistedState);
|
||||
return expect(metrics).resolves.toMatchObject({WASTED_BYTES: 0});
|
||||
});
|
||||
|
||||
test('the metric reducer with the events data but with no imageData in imagesMap ', () => {
|
||||
const persistedState = mockPersistedState(
|
||||
[
|
||||
{
|
||||
width: 50,
|
||||
height: 10,
|
||||
},
|
||||
{
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
{
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
],
|
||||
{
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
);
|
||||
persistedState.imagesMap = {};
|
||||
const metricsReducer = FrescoPlugin.metricsReducer;
|
||||
expect(metricsReducer).toBeDefined();
|
||||
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
|
||||
const metrics = metricsReducer(persistedState);
|
||||
return expect(metrics).resolves.toMatchObject({WASTED_BYTES: 0});
|
||||
});
|
||||
|
||||
test('the metric reducer with the no viewPort data in events', () => {
|
||||
const persistedState = mockPersistedState(
|
||||
[
|
||||
{
|
||||
width: 50,
|
||||
height: 10,
|
||||
},
|
||||
{
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
{
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
],
|
||||
{
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
);
|
||||
delete persistedState.events[0].viewport;
|
||||
const metricsReducer = FrescoPlugin.metricsReducer;
|
||||
expect(metricsReducer).toBeDefined();
|
||||
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
|
||||
const metrics = metricsReducer(persistedState);
|
||||
return expect(metrics).resolves.toMatchObject({WASTED_BYTES: 0});
|
||||
});
|
||||
test('the metric reducer with the multiple events', () => {
|
||||
const scanDisplayTime: ScanDisplayTime = {};
|
||||
scanDisplayTime[1] = 3;
|
||||
const events: Array<ImageEventWithId> = [
|
||||
{
|
||||
imageIds: ['0', '1'],
|
||||
eventId: 0,
|
||||
attribution: [],
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
source: 'source',
|
||||
coldStart: true,
|
||||
viewport: {width: 100, height: 100, scanDisplayTime},
|
||||
},
|
||||
{
|
||||
imageIds: ['2', '3'],
|
||||
eventId: 1,
|
||||
attribution: [],
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
source: 'source',
|
||||
coldStart: true,
|
||||
viewport: {width: 50, height: 50, scanDisplayTime},
|
||||
},
|
||||
];
|
||||
const imageSizes = [
|
||||
{
|
||||
width: 150,
|
||||
height: 150,
|
||||
},
|
||||
{
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
{
|
||||
width: 250,
|
||||
height: 250,
|
||||
},
|
||||
{
|
||||
width: 300,
|
||||
height: 300,
|
||||
},
|
||||
];
|
||||
const imagesMap = imageSizes.reduce((acc, val, index) => {
|
||||
acc[index] = {
|
||||
imageId: String(index),
|
||||
width: val.width,
|
||||
height: val.height,
|
||||
sizeBytes: 10,
|
||||
data: 'undefined',
|
||||
};
|
||||
return acc;
|
||||
}, {} as ImagesMap);
|
||||
const persistedState = {
|
||||
surfaceList: new Set<string>(),
|
||||
images: [],
|
||||
nextEventId: 0,
|
||||
events,
|
||||
imagesMap,
|
||||
closeableReferenceLeaks: [],
|
||||
isLeakTrackingEnabled: true,
|
||||
};
|
||||
const metricsReducer = FrescoPlugin.metricsReducer;
|
||||
expect(metricsReducer).toBeDefined();
|
||||
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
|
||||
const metrics = metricsReducer(persistedState);
|
||||
return expect(metrics).resolves.toMatchObject({WASTED_BYTES: 160000});
|
||||
});
|
||||
|
||||
test('closeable reference metrics on empty state', () => {
|
||||
const metricsReducer: (
|
||||
persistedState: PersistedState,
|
||||
) => Promise<MetricType> = FrescoPlugin.metricsReducer;
|
||||
const persistedState = mockPersistedState();
|
||||
const metrics = metricsReducer(persistedState);
|
||||
return expect(metrics).resolves.toMatchObject({CLOSEABLE_REFERENCE_LEAKS: 0});
|
||||
});
|
||||
|
||||
test('closeable reference metrics on input', () => {
|
||||
const metricsReducer: (
|
||||
persistedState: PersistedState,
|
||||
) => Promise<MetricType> = FrescoPlugin.metricsReducer;
|
||||
const closeableReferenceLeaks: Array<AndroidCloseableReferenceLeakEvent> = [
|
||||
{
|
||||
identityHashCode: 'deadbeef',
|
||||
className: 'com.facebook.imagepipeline.memory.NativeMemoryChunk',
|
||||
stacktrace: null,
|
||||
},
|
||||
{
|
||||
identityHashCode: 'f4c3b00c',
|
||||
className: 'com.facebook.flipper.SomeMemoryAbstraction',
|
||||
stacktrace: null,
|
||||
},
|
||||
];
|
||||
const persistedState = {
|
||||
...mockPersistedState(),
|
||||
closeableReferenceLeaks,
|
||||
};
|
||||
const metrics = metricsReducer(persistedState);
|
||||
return expect(metrics).resolves.toMatchObject({CLOSEABLE_REFERENCE_LEAKS: 2});
|
||||
});
|
||||
|
||||
test('notifications for leaks', () => {
|
||||
const notificationReducer: (
|
||||
persistedState: PersistedState,
|
||||
) => Array<Notification> = FrescoPlugin.getActiveNotifications;
|
||||
const closeableReferenceLeaks: Array<AndroidCloseableReferenceLeakEvent> = [
|
||||
{
|
||||
identityHashCode: 'deadbeef',
|
||||
className: 'com.facebook.imagepipeline.memory.NativeMemoryChunk',
|
||||
stacktrace: null,
|
||||
},
|
||||
{
|
||||
identityHashCode: 'f4c3b00c',
|
||||
className: 'com.facebook.flipper.SomeMemoryAbstraction',
|
||||
stacktrace: null,
|
||||
},
|
||||
];
|
||||
const persistedStateWithoutTracking = {
|
||||
...mockPersistedState(),
|
||||
closeableReferenceLeaks,
|
||||
isLeakTrackingEnabled: false,
|
||||
};
|
||||
const emptyNotifs = notificationReducer(persistedStateWithoutTracking);
|
||||
expect(emptyNotifs).toHaveLength(0);
|
||||
|
||||
const persistedStateWithTracking = {
|
||||
...mockPersistedState(),
|
||||
closeableReferenceLeaks,
|
||||
isLeakTrackingEnabled: true,
|
||||
};
|
||||
const notifs = notificationReducer(persistedStateWithTracking);
|
||||
expect(notifs).toHaveLength(2);
|
||||
expect(notifs[0].message).toMatchSnapshot();
|
||||
expect(notifs[1].title).toContain('SomeMemoryAbstraction');
|
||||
});
|
||||
77
desktop/plugins/fresco/api.tsx
Normal file
77
desktop/plugins/fresco/api.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
export type ImageId = string;
|
||||
|
||||
// Listing images
|
||||
|
||||
export type CacheInfo = {
|
||||
cacheType: string;
|
||||
clearKey?: string; // set this if this cache level supports clear(<key>)
|
||||
sizeBytes: number;
|
||||
maxSizeBytes?: number;
|
||||
imageIds: Array<ImageId>;
|
||||
};
|
||||
|
||||
export type ImagesList = Array<CacheInfo>;
|
||||
|
||||
// The iOS Flipper api does not support a top-level array, so we wrap it in an object
|
||||
export type ImagesListResponse = {
|
||||
levels: ImagesList;
|
||||
};
|
||||
|
||||
// listImages() -> ImagesListResponse
|
||||
|
||||
// Getting details on a specific image
|
||||
|
||||
export type ImageBytes = string;
|
||||
|
||||
export type ImageData = {
|
||||
imageId: ImageId;
|
||||
uri?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sizeBytes: number;
|
||||
data: ImageBytes;
|
||||
surface?: string;
|
||||
};
|
||||
|
||||
// getImage({imageId: string}) -> ImageData
|
||||
|
||||
// Subscribing to image events (requests and prefetches)
|
||||
|
||||
export type Timestamp = number;
|
||||
|
||||
export type ViewportData = {
|
||||
width: number;
|
||||
height: number;
|
||||
scanDisplayTime: {[scan_number: number]: Timestamp};
|
||||
};
|
||||
|
||||
export type ImageEvent = {
|
||||
imageIds: Array<ImageId>;
|
||||
attribution: Array<string>;
|
||||
startTime: Timestamp;
|
||||
endTime: Timestamp;
|
||||
source: string;
|
||||
coldStart: boolean;
|
||||
viewport?: ViewportData; // not set for prefetches
|
||||
};
|
||||
|
||||
// Misc
|
||||
|
||||
export type FrescoDebugOverlayEvent = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type AndroidCloseableReferenceLeakEvent = {
|
||||
identityHashCode: string;
|
||||
className: string;
|
||||
stacktrace: string | null;
|
||||
};
|
||||
500
desktop/plugins/fresco/index.tsx
Normal file
500
desktop/plugins/fresco/index.tsx
Normal file
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* 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 {
|
||||
ImageId,
|
||||
ImageData,
|
||||
ImagesList,
|
||||
ImagesListResponse,
|
||||
ImageEvent,
|
||||
FrescoDebugOverlayEvent,
|
||||
AndroidCloseableReferenceLeakEvent,
|
||||
CacheInfo,
|
||||
} from './api';
|
||||
import {Fragment} from 'react';
|
||||
import {ImagesMap} from './ImagePool';
|
||||
import {MetricType, ReduxState} from 'flipper';
|
||||
import React from 'react';
|
||||
import ImagesCacheOverview from './ImagesCacheOverview';
|
||||
import {
|
||||
FlipperPlugin,
|
||||
FlexRow,
|
||||
Text,
|
||||
DetailSidebar,
|
||||
colors,
|
||||
styled,
|
||||
isProduction,
|
||||
Notification,
|
||||
BaseAction,
|
||||
} from 'flipper';
|
||||
import ImagesSidebar from './ImagesSidebar';
|
||||
import ImagePool from './ImagePool';
|
||||
|
||||
export type ImageEventWithId = ImageEvent & {eventId: number};
|
||||
|
||||
export type PersistedState = {
|
||||
surfaceList: Set<string>;
|
||||
images: ImagesList;
|
||||
events: Array<ImageEventWithId>;
|
||||
imagesMap: ImagesMap;
|
||||
closeableReferenceLeaks: Array<AndroidCloseableReferenceLeakEvent>;
|
||||
isLeakTrackingEnabled: boolean;
|
||||
nextEventId: number;
|
||||
};
|
||||
|
||||
type PluginState = {
|
||||
selectedSurfaces: Set<string>;
|
||||
selectedImage: ImageId | null;
|
||||
isDebugOverlayEnabled: boolean;
|
||||
isAutoRefreshEnabled: boolean;
|
||||
images: ImagesList;
|
||||
coldStartFilter: boolean;
|
||||
};
|
||||
|
||||
const EmptySidebar = styled(FlexRow)({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: colors.light30,
|
||||
padding: 15,
|
||||
fontSize: 16,
|
||||
});
|
||||
|
||||
export const InlineFlexRow = styled(FlexRow)({
|
||||
display: 'inline-block',
|
||||
});
|
||||
|
||||
const surfaceDefaultText = 'SELECT ALL SURFACES';
|
||||
|
||||
const debugLog = (...args: any[]) => {
|
||||
if (!isProduction()) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(...args);
|
||||
}
|
||||
};
|
||||
|
||||
type ImagesMetaData = {
|
||||
levels: ImagesListResponse;
|
||||
events: Array<ImageEventWithId>;
|
||||
imageDataList: Array<ImageData>;
|
||||
};
|
||||
|
||||
export default class FlipperImagesPlugin extends FlipperPlugin<
|
||||
PluginState,
|
||||
BaseAction,
|
||||
PersistedState
|
||||
> {
|
||||
static defaultPersistedState: PersistedState = {
|
||||
images: [],
|
||||
events: [],
|
||||
imagesMap: {},
|
||||
surfaceList: new Set(),
|
||||
closeableReferenceLeaks: [],
|
||||
isLeakTrackingEnabled: false,
|
||||
nextEventId: 0,
|
||||
};
|
||||
|
||||
static exportPersistedState = (
|
||||
callClient: (method: string, params?: any) => Promise<any>,
|
||||
persistedState: PersistedState,
|
||||
store?: ReduxState,
|
||||
): Promise<PersistedState> => {
|
||||
const defaultPromise = Promise.resolve(persistedState);
|
||||
if (!persistedState) {
|
||||
persistedState = FlipperImagesPlugin.defaultPersistedState;
|
||||
}
|
||||
if (!store) {
|
||||
return defaultPromise;
|
||||
}
|
||||
return Promise.all([
|
||||
callClient('listImages'),
|
||||
callClient('getAllImageEventsInfo'),
|
||||
]).then(async ([responseImages, responseEvents]) => {
|
||||
const levels: ImagesList = responseImages.levels;
|
||||
const events: Array<ImageEventWithId> = responseEvents.events;
|
||||
let pluginData: PersistedState = {
|
||||
...persistedState,
|
||||
images: persistedState ? [...persistedState.images, ...levels] : levels,
|
||||
closeableReferenceLeaks:
|
||||
(persistedState && persistedState.closeableReferenceLeaks) || [],
|
||||
};
|
||||
|
||||
events.forEach((event: ImageEventWithId, index) => {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
const {attribution} = event;
|
||||
if (
|
||||
attribution &&
|
||||
attribution instanceof Array &&
|
||||
attribution.length > 0
|
||||
) {
|
||||
const surface = attribution[0] ? attribution[0].trim() : undefined;
|
||||
if (surface && surface.length > 0) {
|
||||
pluginData.surfaceList.add(surface);
|
||||
}
|
||||
}
|
||||
pluginData = {
|
||||
...pluginData,
|
||||
events: [{eventId: index, ...event}, ...pluginData.events],
|
||||
};
|
||||
});
|
||||
const idSet: Set<string> = levels.reduce((acc, level: CacheInfo) => {
|
||||
level.imageIds.forEach(id => {
|
||||
acc.add(id);
|
||||
});
|
||||
return acc;
|
||||
}, new Set<string>());
|
||||
const imageDataList: Array<ImageData> = [];
|
||||
for (const id of idSet) {
|
||||
try {
|
||||
const imageData: ImageData = await callClient('getImage', {
|
||||
imageId: id,
|
||||
});
|
||||
imageDataList.push(imageData);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
imageDataList.forEach((data: ImageData) => {
|
||||
const imagesMap = {...pluginData.imagesMap};
|
||||
imagesMap[data.imageId] = data;
|
||||
pluginData.imagesMap = imagesMap;
|
||||
});
|
||||
return pluginData;
|
||||
});
|
||||
};
|
||||
|
||||
static persistedStateReducer = (
|
||||
persistedState: PersistedState,
|
||||
method: string,
|
||||
data: AndroidCloseableReferenceLeakEvent | ImageEvent,
|
||||
): PersistedState => {
|
||||
if (method == 'closeable_reference_leak_event') {
|
||||
const event: AndroidCloseableReferenceLeakEvent = data as AndroidCloseableReferenceLeakEvent;
|
||||
return {
|
||||
...persistedState,
|
||||
closeableReferenceLeaks: persistedState.closeableReferenceLeaks.concat(
|
||||
event,
|
||||
),
|
||||
};
|
||||
} else if (method == 'events') {
|
||||
const event: ImageEvent = data as ImageEvent;
|
||||
debugLog('Received events', event);
|
||||
const {surfaceList} = persistedState;
|
||||
const {attribution} = event;
|
||||
if (attribution instanceof Array && attribution.length > 0) {
|
||||
const surface = attribution[0] ? attribution[0].trim() : undefined;
|
||||
if (surface && surface.length > 0) {
|
||||
surfaceList.add(surface);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...persistedState,
|
||||
events: [
|
||||
{eventId: persistedState.nextEventId, ...event},
|
||||
...persistedState.events,
|
||||
],
|
||||
nextEventId: persistedState.nextEventId + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return persistedState;
|
||||
};
|
||||
|
||||
static metricsReducer = (
|
||||
persistedState: PersistedState,
|
||||
): Promise<MetricType> => {
|
||||
const {events, imagesMap, closeableReferenceLeaks} = persistedState;
|
||||
|
||||
const wastedBytes = (events || []).reduce((acc, event) => {
|
||||
const {viewport, imageIds} = event;
|
||||
if (!viewport) {
|
||||
return acc;
|
||||
}
|
||||
return imageIds.reduce((innerAcc, imageID) => {
|
||||
const imageData: ImageData = imagesMap[imageID];
|
||||
if (!imageData) {
|
||||
return innerAcc;
|
||||
}
|
||||
const imageWidth: number = imageData.width;
|
||||
const imageHeight: number = imageData.height;
|
||||
const viewPortWidth: number = viewport.width;
|
||||
const viewPortHeight: number = viewport.height;
|
||||
const viewPortArea = viewPortWidth * viewPortHeight;
|
||||
const imageArea = imageWidth * imageHeight;
|
||||
return innerAcc + Math.max(0, imageArea - viewPortArea);
|
||||
}, acc);
|
||||
}, 0);
|
||||
|
||||
return Promise.resolve({
|
||||
WASTED_BYTES: wastedBytes,
|
||||
CLOSEABLE_REFERENCE_LEAKS: (closeableReferenceLeaks || []).length,
|
||||
});
|
||||
};
|
||||
|
||||
static getActiveNotifications = ({
|
||||
closeableReferenceLeaks = [],
|
||||
isLeakTrackingEnabled = false,
|
||||
}: PersistedState): Array<Notification> =>
|
||||
closeableReferenceLeaks
|
||||
.filter(_ => isLeakTrackingEnabled)
|
||||
.map((event: AndroidCloseableReferenceLeakEvent) => ({
|
||||
id: event.identityHashCode,
|
||||
title: `Leaked CloseableReference: ${event.className}`,
|
||||
message: (
|
||||
<Fragment>
|
||||
<InlineFlexRow>
|
||||
CloseableReference leaked for{' '}
|
||||
<Text code={true}>{event.className}</Text>
|
||||
(identity hashcode: {event.identityHashCode}).
|
||||
</InlineFlexRow>
|
||||
<InlineFlexRow>
|
||||
<Text bold={true}>Stacktrace:</Text>
|
||||
</InlineFlexRow>
|
||||
<InlineFlexRow>
|
||||
<Text code={true}>{event.stacktrace || '<unavailable>'}</Text>
|
||||
</InlineFlexRow>
|
||||
</Fragment>
|
||||
),
|
||||
severity: 'error',
|
||||
category: 'closeablereference_leak',
|
||||
}));
|
||||
|
||||
state: PluginState = {
|
||||
selectedSurfaces: new Set([surfaceDefaultText]),
|
||||
selectedImage: null,
|
||||
isDebugOverlayEnabled: false,
|
||||
isAutoRefreshEnabled: false,
|
||||
images: [],
|
||||
coldStartFilter: false,
|
||||
};
|
||||
imagePool: ImagePool | undefined;
|
||||
nextEventId: number = 1;
|
||||
|
||||
filterImages = (
|
||||
images: ImagesList,
|
||||
events: Array<ImageEventWithId>,
|
||||
surfaces: Set<string>,
|
||||
coldStart: boolean,
|
||||
): ImagesList => {
|
||||
if (!surfaces || (surfaces.has(surfaceDefaultText) && !coldStart)) {
|
||||
return images;
|
||||
}
|
||||
|
||||
const imageList = images.map((image: CacheInfo) => {
|
||||
const imageIdList = image.imageIds.filter(imageID => {
|
||||
const filteredEvents = events.filter((event: ImageEventWithId) => {
|
||||
const output =
|
||||
event.attribution &&
|
||||
event.attribution.length > 0 &&
|
||||
event.imageIds &&
|
||||
event.imageIds.includes(imageID);
|
||||
|
||||
if (surfaces.has(surfaceDefaultText)) {
|
||||
return output && coldStart && event.coldStart;
|
||||
}
|
||||
|
||||
return (
|
||||
(!coldStart || (coldStart && event.coldStart)) &&
|
||||
output &&
|
||||
surfaces.has(event.attribution[0])
|
||||
);
|
||||
});
|
||||
return filteredEvents.length > 0;
|
||||
});
|
||||
return {...image, imageIds: imageIdList};
|
||||
});
|
||||
return imageList;
|
||||
};
|
||||
|
||||
init() {
|
||||
debugLog('init()');
|
||||
this.updateCaches('init');
|
||||
this.client.subscribe(
|
||||
'debug_overlay_event',
|
||||
(event: FrescoDebugOverlayEvent) => {
|
||||
this.setState({isDebugOverlayEnabled: event.enabled});
|
||||
},
|
||||
);
|
||||
this.imagePool = new ImagePool(this.getImage, (images: ImagesMap) =>
|
||||
this.props.setPersistedState({imagesMap: images}),
|
||||
);
|
||||
|
||||
const images = this.filterImages(
|
||||
this.props.persistedState.images,
|
||||
this.props.persistedState.events,
|
||||
this.state.selectedSurfaces,
|
||||
this.state.coldStartFilter,
|
||||
);
|
||||
|
||||
this.setState({images});
|
||||
}
|
||||
|
||||
teardown() {
|
||||
this.imagePool ? this.imagePool.clear() : undefined;
|
||||
}
|
||||
|
||||
updateImagesOnUI = (
|
||||
images: ImagesList,
|
||||
surfaces: Set<string>,
|
||||
coldStart: boolean,
|
||||
) => {
|
||||
const filteredImages = this.filterImages(
|
||||
images,
|
||||
this.props.persistedState.events,
|
||||
surfaces,
|
||||
coldStart,
|
||||
);
|
||||
|
||||
this.setState({
|
||||
selectedSurfaces: surfaces,
|
||||
images: filteredImages,
|
||||
coldStartFilter: coldStart,
|
||||
});
|
||||
};
|
||||
updateCaches = (reason: string) => {
|
||||
debugLog('Requesting images list (reason=' + reason + ')');
|
||||
this.client.call('listImages').then((response: ImagesListResponse) => {
|
||||
response.levels.forEach(data =>
|
||||
this.imagePool ? this.imagePool.fetchImages(data.imageIds) : undefined,
|
||||
);
|
||||
this.props.setPersistedState({images: response.levels});
|
||||
this.updateImagesOnUI(
|
||||
this.props.persistedState.images,
|
||||
this.state.selectedSurfaces,
|
||||
this.state.coldStartFilter,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
onClear = (type: string) => {
|
||||
this.client.call('clear', {type});
|
||||
setTimeout(() => this.updateCaches('onClear'), 1000);
|
||||
};
|
||||
|
||||
onTrimMemory = () => {
|
||||
this.client.call('trimMemory', {});
|
||||
setTimeout(() => this.updateCaches('onTrimMemory'), 1000);
|
||||
};
|
||||
|
||||
onEnableDebugOverlay = (enabled: boolean) => {
|
||||
this.client.call('enableDebugOverlay', {enabled});
|
||||
};
|
||||
|
||||
onEnableAutoRefresh = (enabled: boolean) => {
|
||||
this.setState({isAutoRefreshEnabled: enabled});
|
||||
if (enabled) {
|
||||
// Delay the call just enough to allow the state change to complete.
|
||||
setTimeout(() => this.onAutoRefresh());
|
||||
}
|
||||
};
|
||||
|
||||
onAutoRefresh = () => {
|
||||
this.updateCaches('auto-refresh');
|
||||
if (this.state.isAutoRefreshEnabled) {
|
||||
setTimeout(() => this.onAutoRefresh(), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
getImage = (imageId: string) => {
|
||||
debugLog('<- getImage requested for ' + imageId);
|
||||
this.client.call('getImage', {imageId}).then((image: ImageData) => {
|
||||
debugLog('-> getImage ' + imageId + ' returned');
|
||||
this.imagePool ? this.imagePool._fetchCompleted(image) : undefined;
|
||||
});
|
||||
};
|
||||
|
||||
onImageSelected = (selectedImage: ImageId) => this.setState({selectedImage});
|
||||
|
||||
renderSidebar = () => {
|
||||
const {selectedImage} = this.state;
|
||||
|
||||
if (selectedImage == null) {
|
||||
return (
|
||||
<EmptySidebar grow={true}>
|
||||
<Text align="center">
|
||||
Select an image to see the events associated with it.
|
||||
</Text>
|
||||
</EmptySidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const maybeImage = this.props.persistedState.imagesMap[selectedImage];
|
||||
const events = this.props.persistedState.events.filter(e =>
|
||||
e.imageIds.includes(selectedImage),
|
||||
);
|
||||
return <ImagesSidebar image={maybeImage} events={events} />;
|
||||
};
|
||||
|
||||
onSurfaceChange = (surfaces: Set<string>) => {
|
||||
this.updateImagesOnUI(
|
||||
this.props.persistedState.images,
|
||||
surfaces,
|
||||
this.state.coldStartFilter,
|
||||
);
|
||||
};
|
||||
|
||||
onColdStartChange = (checked: boolean) => {
|
||||
this.updateImagesOnUI(
|
||||
this.props.persistedState.images,
|
||||
this.state.selectedSurfaces,
|
||||
checked,
|
||||
);
|
||||
};
|
||||
|
||||
onTrackLeaks = (checked: boolean) => {
|
||||
this.props.logger.track('usage', 'fresco:onTrackLeaks', {enabled: checked});
|
||||
this.props.setPersistedState({
|
||||
isLeakTrackingEnabled: checked,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const options = [...this.props.persistedState.surfaceList].reduce(
|
||||
(acc, item) => {
|
||||
return [...acc, item];
|
||||
},
|
||||
[surfaceDefaultText],
|
||||
);
|
||||
let {selectedSurfaces} = this.state;
|
||||
|
||||
if (selectedSurfaces.has(surfaceDefaultText)) {
|
||||
selectedSurfaces = new Set(options);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ImagesCacheOverview
|
||||
allSurfacesOption={surfaceDefaultText}
|
||||
surfaceOptions={new Set(options)}
|
||||
selectedSurfaces={selectedSurfaces}
|
||||
onChangeSurface={this.onSurfaceChange}
|
||||
coldStartFilter={this.state.coldStartFilter}
|
||||
onColdStartChange={this.onColdStartChange}
|
||||
images={this.state.images}
|
||||
onClear={this.onClear}
|
||||
onTrimMemory={this.onTrimMemory}
|
||||
onRefresh={() => this.updateCaches('refresh')}
|
||||
onEnableDebugOverlay={this.onEnableDebugOverlay}
|
||||
onEnableAutoRefresh={this.onEnableAutoRefresh}
|
||||
isDebugOverlayEnabled={this.state.isDebugOverlayEnabled}
|
||||
isAutoRefreshEnabled={this.state.isAutoRefreshEnabled}
|
||||
onImageSelected={this.onImageSelected}
|
||||
imagesMap={this.props.persistedState.imagesMap}
|
||||
events={this.props.persistedState.events}
|
||||
isLeakTrackingEnabled={
|
||||
this.props.persistedState.isLeakTrackingEnabled
|
||||
}
|
||||
onTrackLeaks={this.onTrackLeaks}
|
||||
/>
|
||||
<DetailSidebar>{this.renderSidebar()}</DetailSidebar>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
12
desktop/plugins/fresco/package.json
Normal file
12
desktop/plugins/fresco/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Fresco",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": ["flipper-plugin"],
|
||||
"title": "Images",
|
||||
"icon": "profile",
|
||||
"bugs": {
|
||||
"email": "oncall+fresco@xmail.facebook.com"
|
||||
}
|
||||
}
|
||||
4
desktop/plugins/fresco/yarn.lock
Normal file
4
desktop/plugins/fresco/yarn.lock
Normal file
@@ -0,0 +1,4 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user