Plugin folders re-structuring
Summary: Here I'm changing plugin repository structure to allow re-using of shared packages between both public and fb-internal plugins, and to ensure that public plugins has their own yarn.lock as this will be required to implement reproducible jobs checking plugin compatibility with released flipper versions. Please note that there are a lot of moved files in this diff, make sure to click "Expand all" to see all that actually changed (there are not much of them actually). New proposed structure for plugin packages: ``` - root - node_modules - modules included into Flipper: flipper, flipper-plugin, react, antd, emotion -- plugins --- node_modules - modules used by both public and fb-internal plugins (shared libs will be linked here, see D27034936) --- public ---- node_modules - modules used by public plugins ---- pluginA ----- node_modules - modules used by plugin A exclusively ---- pluginB ----- node_modules - modules used by plugin B exclusively --- fb ---- node_modules - modules used by fb-internal plugins ---- pluginC ----- node_modules - modules used by plugin C exclusively ---- pluginD ----- node_modules - modules used by plugin D exclusively ``` I've moved all public plugins under dir "plugins/public" and excluded them from root yarn workspaces. Instead, they will have their own yarn workspaces config and yarn.lock and they will use flipper modules as peer dependencies. Reviewed By: mweststrate Differential Revision: D27034108 fbshipit-source-id: c2310e3c5bfe7526033f51b46c0ae40199fd7586
This commit is contained in:
committed by
Facebook GitHub Bot
parent
32bf4c32c2
commit
b3274a8450
76
desktop/plugins/public/cpu/TemperatureTable.tsx
Normal file
76
desktop/plugins/public/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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
683
desktop/plugins/public/cpu/index.tsx
Normal file
683
desktop/plugins/public/cpu/index.tsx
Normal file
@@ -0,0 +1,683 @@
|
||||
/**
|
||||
* 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 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' &&
|
||||
!device.isArchived
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
25
desktop/plugins/public/cpu/package.json
Normal file
25
desktop/plugins/public/cpu/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-device-cpu",
|
||||
"id": "DeviceCPU",
|
||||
"pluginType": "device",
|
||||
"supportedDevices": [
|
||||
{
|
||||
"os": "Android",
|
||||
"type": "physical",
|
||||
"archived": false
|
||||
}
|
||||
],
|
||||
"version": "0.0.0",
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"title": "CPU",
|
||||
"icon": "underline",
|
||||
"bugs": {
|
||||
"email": "barney@fb.com"
|
||||
}
|
||||
}
|
||||
@@ -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 {BaseDevice} from 'flipper';
|
||||
import {Crash, shouldShowiOSCrashNotification} from '../index';
|
||||
import {parseCrashLog, parsePath} from '../index';
|
||||
import {TestUtils} from 'flipper-plugin';
|
||||
import {getPluginKey} from 'flipper';
|
||||
import * as CrashReporterPlugin from '../index';
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
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', null);
|
||||
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', undefined);
|
||||
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', undefined);
|
||||
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', undefined);
|
||||
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', undefined);
|
||||
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', undefined);
|
||||
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', null);
|
||||
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', null);
|
||||
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', 'iOS');
|
||||
const pluginKey = getPluginKey(null, device, 'CrashReporter');
|
||||
expect(pluginKey).toEqual('serial#CrashReporter');
|
||||
});
|
||||
test('test the getter of pluginKey with undefined input', () => {
|
||||
const pluginKey = getPluginKey(null, null, 'CrashReporter');
|
||||
expect(pluginKey).toEqual('unknown#CrashReporter');
|
||||
});
|
||||
test('test the getter of pluginKey with defined selected app', () => {
|
||||
const pluginKey = getPluginKey('selectedApp', null, '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', 'iOS');
|
||||
const pluginKey = getPluginKey('selectedApp', device, 'CrashReporter');
|
||||
expect(pluginKey).toEqual('selectedApp#CrashReporter');
|
||||
});
|
||||
test('test defaultPersistedState of CrashReporterPlugin', () => {
|
||||
expect(
|
||||
TestUtils.startDevicePlugin(CrashReporterPlugin).exportState(),
|
||||
).toEqual({crashes: []});
|
||||
});
|
||||
test('test helper setdefaultPersistedState function', () => {
|
||||
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
|
||||
const plugin = TestUtils.startDevicePlugin(CrashReporterPlugin);
|
||||
plugin.instance.reportCrash(crash);
|
||||
expect(plugin.exportState()).toEqual({crashes: [crash]});
|
||||
});
|
||||
test('test getNewPersistedStateFromCrashLog for non-empty defaultPersistedState and defined pluginState', () => {
|
||||
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
|
||||
const plugin = TestUtils.startDevicePlugin(CrashReporterPlugin);
|
||||
plugin.instance.reportCrash(crash);
|
||||
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
|
||||
plugin.instance.reportCrash(pluginStateCrash);
|
||||
const crashes = plugin.instance.crashes.get();
|
||||
expect(crashes).toBeDefined();
|
||||
expect(crashes.length).toEqual(2);
|
||||
expect(crashes[1]).toEqual(pluginStateCrash);
|
||||
});
|
||||
|
||||
test('test getNewPersistedStateFromCrashLog for non-empty defaultPersistedState and defined pluginState and improper crash log', () => {
|
||||
const plugin = TestUtils.startDevicePlugin(CrashReporterPlugin);
|
||||
const pluginStateCrash = getCrash(0, 'callstack', 'crash1', 'crash1');
|
||||
plugin.instance.reportCrash(pluginStateCrash);
|
||||
const content = 'Blaa Blaaa \n Blaa Blaaa';
|
||||
plugin.instance.reportCrash(parseCrashLog(content, 'iOS', null));
|
||||
const crashes = plugin.instance.crashes.get();
|
||||
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 getNewPersistedStateFromCrashLog when os is undefined', () => {
|
||||
const plugin = TestUtils.startDevicePlugin(CrashReporterPlugin);
|
||||
const content = 'Blaa Blaaa \n Blaa Blaaa';
|
||||
expect(() => {
|
||||
plugin.instance.reportCrash(parseCrashLog(content, undefined as any, null));
|
||||
}).toThrowErrorMatchingInlineSnapshot(`"Unsupported OS"`);
|
||||
const crashes = plugin.instance.crashes.get();
|
||||
expect(crashes.length).toEqual(0);
|
||||
});
|
||||
|
||||
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',
|
||||
'iOS',
|
||||
);
|
||||
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 = shouldShowiOSCrashNotification(
|
||||
device.serial,
|
||||
content,
|
||||
);
|
||||
expect(shouldShowNotification).toEqual(true);
|
||||
});
|
||||
test('test shouldShowiOSCrashNotification function for all correct inputs but incorrect id', () => {
|
||||
const device = new BaseDevice(
|
||||
'TH1S-15DEV1CE-1D',
|
||||
'emulator',
|
||||
'test device',
|
||||
'iOS',
|
||||
);
|
||||
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 = shouldShowiOSCrashNotification(
|
||||
device.serial,
|
||||
content,
|
||||
);
|
||||
expect(shouldShowNotification).toEqual(false);
|
||||
});
|
||||
test('test shouldShowiOSCrashNotification 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 = shouldShowiOSCrashNotification(
|
||||
null as any,
|
||||
content,
|
||||
);
|
||||
expect(shouldShowNotification).toEqual(false);
|
||||
});
|
||||
|
||||
test('only crashes from the correct device are picked up', () => {
|
||||
const serial = 'AC9482A2-26A4-404F-A179-A9FB60B077F6';
|
||||
const crash = `Process: Sample [87361]
|
||||
Path: /Users/USER/Library/Developer/CoreSimulator/Devices/AC9482A2-26A4-404F-A179-A9FB60B077F6/data/Containers/Bundle/Application/9BF91EF9-F915-4745-BE91-EBA397451850/Sample.app/Sample
|
||||
Identifier: Sample
|
||||
Version: 1.0 (1)
|
||||
Code Type: X86-64 (Native)
|
||||
Parent Process: launchd_sim [70150]
|
||||
Responsible: SimulatorTrampoline [1246]
|
||||
User ID: 501`;
|
||||
|
||||
expect(shouldShowiOSCrashNotification(serial, crash)).toBe(true);
|
||||
// wrong serial
|
||||
expect(
|
||||
shouldShowiOSCrashNotification(
|
||||
'XC9482A2-26A4-404F-A179-A9FB60B077F6',
|
||||
crash,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
652
desktop/plugins/public/crash_reporter/index.tsx
Normal file
652
desktop/plugins/public/crash_reporter/index.tsx
Normal file
@@ -0,0 +1,652 @@
|
||||
/**
|
||||
* 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 {
|
||||
View,
|
||||
styled,
|
||||
FlexColumn,
|
||||
FlexRow,
|
||||
ContextMenu,
|
||||
clipboard,
|
||||
Button,
|
||||
shouldParseAndroidLog,
|
||||
Text,
|
||||
colors,
|
||||
Toolbar,
|
||||
Spacer,
|
||||
Select,
|
||||
} from 'flipper';
|
||||
import unicodeSubstring from 'unicode-substring';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {promisify} from 'util';
|
||||
import type {DeviceLogEntry} from 'flipper';
|
||||
import React from 'react';
|
||||
import {
|
||||
createState,
|
||||
DevicePluginClient,
|
||||
usePlugin,
|
||||
useValue,
|
||||
} from 'flipper-plugin';
|
||||
import type {FSWatcher} from 'fs';
|
||||
|
||||
type Maybe<T> = T | null | undefined;
|
||||
|
||||
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: (name: Maybe<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: Maybe<Date>;
|
||||
};
|
||||
|
||||
const Padder = styled.div<{
|
||||
paddingLeft?: number;
|
||||
paddingRight?: number;
|
||||
paddingBottom?: number;
|
||||
paddingTop?: number;
|
||||
}>(({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 parseCrashLog(
|
||||
content: string,
|
||||
os: string,
|
||||
logDate: Maybe<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: Maybe<string> =
|
||||
tmp1 && tmp1[0].length ? tmp1[0] : null;
|
||||
date = extractedDateString ? new Date(extractedDateString) : logDate;
|
||||
}
|
||||
|
||||
const crash: CrashLog = {
|
||||
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 reasonText =
|
||||
remainingString.length > 0
|
||||
? remainingString.split('\n').pop()
|
||||
: fallbackReason;
|
||||
const reason = reasonText ? reasonText : fallbackReason;
|
||||
if (name[name.length - 1] === '\n') {
|
||||
name = name.slice(0, -1);
|
||||
}
|
||||
const crash: CrashLog = {
|
||||
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): Maybe<string> {
|
||||
const regex = /(?<=.*Path: *)[^\n]*/;
|
||||
const arr = regex.exec(content);
|
||||
if (!arr || arr.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
const path = arr[0];
|
||||
return path.trim();
|
||||
}
|
||||
|
||||
function addFileWatcherForiOSCrashLogs(
|
||||
deviceOs: string,
|
||||
serial: string,
|
||||
reportCrash: (payload: CrashLog | Crash) => void,
|
||||
) {
|
||||
const dir = path.join(os.homedir(), 'Library', 'Logs', 'DiagnosticReports');
|
||||
if (!fs.existsSync(dir)) {
|
||||
// Directory doesn't exist
|
||||
return;
|
||||
}
|
||||
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 (err) {
|
||||
console.warn('Failed to read crash file', err);
|
||||
return;
|
||||
}
|
||||
if (shouldShowiOSCrashNotification(serial, data)) {
|
||||
reportCrash(parseCrashLog(data, deviceOs, null));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class CrashSelector extends React.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 && selectedCrashID) {
|
||||
const index = orderedIDs.indexOf(selectedCrashID as string);
|
||||
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 && selectedCrashID) {
|
||||
const index = orderedIDs.indexOf(selectedCrashID as string);
|
||||
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 React.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 React.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 React.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 function devicePlugin(client: DevicePluginClient) {
|
||||
let notificationID = -1;
|
||||
let watcher: FSWatcher | undefined;
|
||||
|
||||
const crashes = createState<Crash[]>([], {persist: 'crashes'});
|
||||
const selectedCrash = createState<string | undefined>();
|
||||
|
||||
client.onDeepLink((crashId) => {
|
||||
selectedCrash.set(crashId as string);
|
||||
});
|
||||
|
||||
function reportCrash(payload: CrashLog | Crash) {
|
||||
notificationID++;
|
||||
|
||||
const crash = {
|
||||
notificationID: notificationID.toString(),
|
||||
callstack: payload.callstack,
|
||||
name: payload.name,
|
||||
reason: payload.reason,
|
||||
date: payload.date || new Date(),
|
||||
};
|
||||
|
||||
crashes.update((draft) => {
|
||||
draft.push(crash);
|
||||
});
|
||||
|
||||
// show notification?
|
||||
const ignore = !crash.name && !crash.reason;
|
||||
const unknownCrashCause = crash.reason === UNKNOWN_CRASH_REASON;
|
||||
if (ignore || unknownCrashCause) {
|
||||
console.warn('Ignored the notification for the crash', crash);
|
||||
return;
|
||||
}
|
||||
|
||||
let title: string = 'CRASH: ' + truncate(crash.name || crash.reason, 50);
|
||||
title = `${
|
||||
crash.name == crash.reason
|
||||
? title
|
||||
: title + 'Reason: ' + truncate(crash.reason, 50)
|
||||
}`;
|
||||
const callstack = crash.callstack
|
||||
? trimCallStackIfPossible(crash.callstack)
|
||||
: 'No callstack available';
|
||||
const msg = `Callstack: ${truncate(callstack, 200)}`;
|
||||
client.showNotification({
|
||||
id: crash.notificationID,
|
||||
message: msg,
|
||||
severity: 'error',
|
||||
title: title,
|
||||
action: crash.notificationID,
|
||||
category: crash.reason || 'Unknown reason',
|
||||
});
|
||||
}
|
||||
|
||||
// Startup logic to establish log monitoring
|
||||
if (client.device.isConnected) {
|
||||
if (client.device.os.includes('iOS')) {
|
||||
watcher = addFileWatcherForiOSCrashLogs(
|
||||
client.device.os,
|
||||
client.device.serial,
|
||||
reportCrash,
|
||||
);
|
||||
} else {
|
||||
const referenceDate = new Date();
|
||||
let androidLog: string = '';
|
||||
let androidLogUnderProcess = false;
|
||||
let timer: Maybe<NodeJS.Timeout> = null;
|
||||
client.device.onLogEntry((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) {
|
||||
reportCrash(
|
||||
parseCrashLog(androidLog, client.device.os, entry.date),
|
||||
);
|
||||
}
|
||||
androidLogUnderProcess = false;
|
||||
androidLog = '';
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
client.onDestroy(() => {
|
||||
watcher?.close();
|
||||
});
|
||||
|
||||
return {
|
||||
reportCrash,
|
||||
crashes,
|
||||
selectedCrash,
|
||||
openInLogs(callstack: string) {
|
||||
client.selectPlugin('DeviceLogs', callstack);
|
||||
},
|
||||
os: client.device.os,
|
||||
copyCrashToClipboard(callstack: string) {
|
||||
client.writeTextToClipboard(callstack);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function Component() {
|
||||
const plugin = usePlugin(devicePlugin);
|
||||
const selectedCrash = useValue(plugin.selectedCrash);
|
||||
const crashes = useValue(plugin.crashes);
|
||||
const crash =
|
||||
crashes.find((c) => c.notificationID === selectedCrash) ??
|
||||
crashes[crashes.length - 1] ??
|
||||
undefined;
|
||||
|
||||
if (crash) {
|
||||
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: Maybe<string>) => {
|
||||
if (id) {
|
||||
plugin.selectedCrash.set(id);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
{plugin.os == 'Android' ? (
|
||||
<CrashReporterBar
|
||||
crashSelector={crashSelector}
|
||||
openLogsCallback={() => {
|
||||
if (crash.callstack) {
|
||||
plugin.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: () => {
|
||||
plugin.copyCrashToClipboard(callstackString);
|
||||
},
|
||||
},
|
||||
]}>
|
||||
<Line />
|
||||
{children.map((child, index) => {
|
||||
return (
|
||||
<StackTraceComponent key={index} stacktrace={child.message} />
|
||||
);
|
||||
})}
|
||||
</ContextMenu>
|
||||
</ScrollableColumn>
|
||||
</PluginRootContainer>
|
||||
);
|
||||
}
|
||||
const crashSelector = {
|
||||
crashes: undefined,
|
||||
orderedIDs: undefined,
|
||||
selectedCrashID: undefined,
|
||||
onCrashChange: () => void {},
|
||||
};
|
||||
return (
|
||||
<StyledFlexGrowColumn>
|
||||
<CrashReporterBar crashSelector={crashSelector} />
|
||||
<StyledFlexColumn>
|
||||
<Padder paddingBottom={8}>
|
||||
<Title>No Crashes Logged</Title>
|
||||
</Padder>
|
||||
</StyledFlexColumn>
|
||||
</StyledFlexGrowColumn>
|
||||
);
|
||||
}
|
||||
|
||||
function trimCallStackIfPossible(callstack: string): string {
|
||||
const regex = /Application Specific Information:/;
|
||||
const query = regex.exec(callstack);
|
||||
return query ? callstack.substring(0, query.index) : callstack;
|
||||
}
|
||||
|
||||
export function shouldShowiOSCrashNotification(
|
||||
serial: string,
|
||||
content: string,
|
||||
): boolean {
|
||||
const appPath = parsePath(content);
|
||||
if (!appPath || !appPath.includes(serial)) {
|
||||
// Do not show notifications for the app which are not running on this device
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
40
desktop/plugins/public/crash_reporter/package.json
Normal file
40
desktop/plugins/public/crash_reporter/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-crash-reporter",
|
||||
"id": "CrashReporter",
|
||||
"pluginType": "device",
|
||||
"supportedDevices": [
|
||||
{
|
||||
"os": "Android",
|
||||
"type": "emulator"
|
||||
},
|
||||
{
|
||||
"os": "Android",
|
||||
"type": "physical"
|
||||
},
|
||||
{
|
||||
"os": "iOS",
|
||||
"type": "emulator"
|
||||
}
|
||||
],
|
||||
"version": "0.0.0",
|
||||
"description": "A plugin which will display a crash",
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"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"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"flipper-plugin": "0.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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 'unicode-substring' {
|
||||
const unicodeSubstring: (
|
||||
string: string,
|
||||
start: number,
|
||||
end: number,
|
||||
) => string;
|
||||
export default unicodeSubstring;
|
||||
}
|
||||
49
desktop/plugins/public/databases/ButtonNavigation.tsx
Normal file
49
desktop/plugins/public/databases/ButtonNavigation.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
|
||||
*/
|
||||
|
||||
import {Button, ButtonGroup, Glyph, colors} from 'flipper';
|
||||
import React from 'react';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
98
desktop/plugins/public/databases/ClientProtocol.tsx
Normal file
98
desktop/plugins/public/databases/ClientProtocol.tsx
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
|
||||
*/
|
||||
|
||||
import {PluginClient, Value} from 'flipper';
|
||||
|
||||
type ClientCall<Params, Response> = (arg: 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> = () =>
|
||||
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);
|
||||
}
|
||||
222
desktop/plugins/public/databases/DatabaseDetailSidebar.tsx
Normal file
222
desktop/plugins/public/databases/DatabaseDetailSidebar.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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, {useMemo, useState, useEffect, useReducer} from 'react';
|
||||
import {
|
||||
Input,
|
||||
DetailSidebar,
|
||||
Panel,
|
||||
ManagedDataInspector,
|
||||
Value,
|
||||
valueToNullableString,
|
||||
renderValue,
|
||||
Button,
|
||||
styled,
|
||||
produce,
|
||||
colors,
|
||||
} from 'flipper';
|
||||
|
||||
type TableRow = {
|
||||
col: string;
|
||||
type: Value['type'];
|
||||
value: React.ReactElement;
|
||||
};
|
||||
|
||||
type DatabaseDetailSidebarProps = {
|
||||
columnLabels: Array<string>;
|
||||
columnValues: Array<Value>;
|
||||
onSave?: ((changes: {[key: string]: string | null}) => void) | undefined;
|
||||
};
|
||||
|
||||
const EditTriggerSection = styled.div({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
width: '100%',
|
||||
paddingTop: '3px',
|
||||
paddingBottom: '3px',
|
||||
paddingRight: '10px',
|
||||
});
|
||||
|
||||
const TableDetailRow = styled.div({
|
||||
borderBottom: `1px solid ${colors.blackAlpha10}`,
|
||||
padding: 8,
|
||||
});
|
||||
|
||||
const TableDetailRowTitle = styled.div({
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
});
|
||||
|
||||
const TableDetailRowType = styled.span({
|
||||
color: colors.light20,
|
||||
marginLeft: 8,
|
||||
fontWeight: 'normal',
|
||||
});
|
||||
|
||||
const TableDetailRowValue = styled.div({});
|
||||
|
||||
function sidebarRows(labels: Array<string>, values: Array<Value>): TableRow[] {
|
||||
return labels.map((label, idx) => buildSidebarRow(label, values[idx]));
|
||||
}
|
||||
|
||||
function buildSidebarRow(key: string, val: Value): TableRow {
|
||||
let output = renderValue(val, true);
|
||||
if (
|
||||
(val.type === 'string' || val.type === 'blob') &&
|
||||
(val.value[0] === '[' || val.value[0] === '{')
|
||||
) {
|
||||
try {
|
||||
// eslint-disable-next-line
|
||||
var parsed = JSON.parse(val.value);
|
||||
} catch (_error) {}
|
||||
if (parsed) {
|
||||
output = (
|
||||
<ManagedDataInspector data={parsed} expandRoot={true} collapsed />
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
col: key,
|
||||
type: val.type,
|
||||
value: output,
|
||||
};
|
||||
}
|
||||
|
||||
function sidebarEditableRows(
|
||||
labels: Array<string>,
|
||||
values: Array<Value>,
|
||||
rowDispatch: (action: RowAction) => void,
|
||||
): TableRow[] {
|
||||
return labels.map((label, idx) =>
|
||||
buildSidebarEditableRow(label, values[idx], (value: string | null) =>
|
||||
rowDispatch({type: 'set', key: label, value}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildSidebarEditableRow(
|
||||
key: string,
|
||||
val: Value,
|
||||
onUpdateValue: (value: string | null) => void,
|
||||
): TableRow {
|
||||
if (val.type === 'blob' || !val.type) {
|
||||
return buildSidebarRow(key, val);
|
||||
}
|
||||
return {
|
||||
col: key,
|
||||
type: val.type,
|
||||
value: (
|
||||
<EditField
|
||||
key={key}
|
||||
initialValue={valueToNullableString(val)}
|
||||
onUpdateValue={onUpdateValue}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const EditField = React.memo(
|
||||
(props: {
|
||||
initialValue: string | null;
|
||||
onUpdateValue: (value: string | null) => void;
|
||||
}) => {
|
||||
const {initialValue, onUpdateValue} = props;
|
||||
const [value, setValue] = useState<string | null>(initialValue);
|
||||
useEffect(() => setValue(initialValue), [initialValue]);
|
||||
return (
|
||||
<Input
|
||||
value={value || ''}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
onUpdateValue(e.target.value);
|
||||
}}
|
||||
placeholder={value === null ? 'NULL' : undefined}
|
||||
data-testid={'update-query-input'}
|
||||
style={{width: '100%'}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type RowState = {changes: {[key: string]: string | null}; updated: boolean};
|
||||
type RowAction =
|
||||
| {type: 'set'; key: string; value: string | null}
|
||||
| {type: 'reset'};
|
||||
|
||||
const rowStateReducer = produce((draftState: RowState, action: RowAction) => {
|
||||
switch (action.type) {
|
||||
case 'set':
|
||||
draftState.changes[action.key] = action.value;
|
||||
draftState.updated = true;
|
||||
return;
|
||||
case 'reset':
|
||||
draftState.changes = {};
|
||||
draftState.updated = false;
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
export default React.memo(function DatabaseDetailSidebar(
|
||||
props: DatabaseDetailSidebarProps,
|
||||
) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [rowState, rowDispatch] = useReducer(rowStateReducer, {
|
||||
changes: {},
|
||||
updated: false,
|
||||
});
|
||||
const {columnLabels, columnValues, onSave} = props;
|
||||
useEffect(() => rowDispatch({type: 'reset'}), [columnLabels, columnValues]);
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
editing
|
||||
? sidebarEditableRows(columnLabels, columnValues, rowDispatch)
|
||||
: sidebarRows(columnLabels, columnValues),
|
||||
[columnLabels, columnValues, editing],
|
||||
);
|
||||
return (
|
||||
<DetailSidebar>
|
||||
<Panel
|
||||
heading="Row details"
|
||||
floating={false}
|
||||
collapsable={true}
|
||||
padded={false}>
|
||||
{onSave ? (
|
||||
<EditTriggerSection>
|
||||
{editing ? (
|
||||
<>
|
||||
<Button
|
||||
disabled={!rowState.updated}
|
||||
onClick={() => {
|
||||
onSave(rowState.changes);
|
||||
setEditing(false);
|
||||
}}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(false)}>Close</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => setEditing(true)}>Edit</Button>
|
||||
)}
|
||||
</EditTriggerSection>
|
||||
) : null}
|
||||
<div>
|
||||
{rows.map((row) => (
|
||||
<TableDetailRow key={row.col}>
|
||||
<TableDetailRowTitle>
|
||||
{row.col}
|
||||
<TableDetailRowType>({row.type})</TableDetailRowType>
|
||||
</TableDetailRowTitle>
|
||||
<TableDetailRowValue>{row.value}</TableDetailRowValue>
|
||||
</TableDetailRow>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</DetailSidebar>
|
||||
);
|
||||
});
|
||||
91
desktop/plugins/public/databases/DatabaseStructure.tsx
Normal file
91
desktop/plugins/public/databases/DatabaseStructure.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 {
|
||||
FlexRow,
|
||||
ManagedTable,
|
||||
TableBodyRow,
|
||||
TableBodyColumn,
|
||||
Value,
|
||||
renderValue,
|
||||
} from 'flipper';
|
||||
import React, {useMemo} from 'react';
|
||||
|
||||
import {Structure} from './index';
|
||||
|
||||
function transformRow(
|
||||
columns: Array<string>,
|
||||
row: Array<Value>,
|
||||
index: number,
|
||||
): TableBodyRow {
|
||||
const transformedColumns: {[key: string]: TableBodyColumn} = {};
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
transformedColumns[columns[i]] = {value: renderValue(row[i], true)};
|
||||
}
|
||||
return {key: String(index), columns: transformedColumns};
|
||||
}
|
||||
|
||||
const DatabaseStructureManagedTable = React.memo(
|
||||
(props: {columns: Array<string>; rows: Array<Array<Value>>}) => {
|
||||
const {columns, rows} = props;
|
||||
const renderRows = useMemo(
|
||||
() =>
|
||||
rows.map((row: Array<Value>, index: number) =>
|
||||
transformRow(columns, row, index),
|
||||
),
|
||||
[rows, columns],
|
||||
);
|
||||
const renderColumns = useMemo(
|
||||
() =>
|
||||
columns.reduce(
|
||||
(acc, val) =>
|
||||
Object.assign({}, acc, {[val]: {value: val, resizable: true}}),
|
||||
{},
|
||||
),
|
||||
[columns],
|
||||
);
|
||||
const columnOrder = useMemo(
|
||||
() =>
|
||||
columns.map((name) => ({
|
||||
key: name,
|
||||
visible: true,
|
||||
})),
|
||||
[columns],
|
||||
);
|
||||
return (
|
||||
<FlexRow grow={true}>
|
||||
<ManagedTable
|
||||
floating={false}
|
||||
columnOrder={columnOrder}
|
||||
columns={renderColumns}
|
||||
zebra={true}
|
||||
rows={renderRows}
|
||||
horizontallyScrollable={true}
|
||||
/>
|
||||
</FlexRow>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default React.memo((props: {structure: Structure | null}) => {
|
||||
const {structure} = props;
|
||||
if (!structure) {
|
||||
return null;
|
||||
}
|
||||
const {columns, rows, indexesColumns, indexesValues} = structure;
|
||||
return (
|
||||
<>
|
||||
<DatabaseStructureManagedTable columns={columns} rows={rows} />
|
||||
<DatabaseStructureManagedTable
|
||||
columns={indexesColumns}
|
||||
rows={indexesValues}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
92
desktop/plugins/public/databases/UpdateQueryUtil.tsx
Normal file
92
desktop/plugins/public/databases/UpdateQueryUtil.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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 {Value} from 'flipper';
|
||||
|
||||
const INT_DATA_TYPE = ['INTEGER', 'LONG', 'INT', 'BIGINT'];
|
||||
const FLOAT_DATA_TYPE = ['REAL', 'DOUBLE'];
|
||||
const BLOB_DATA_TYPE = ['BLOB'];
|
||||
|
||||
export function convertStringToValue(
|
||||
types: {[key: string]: {type: string; nullable: boolean}},
|
||||
key: string,
|
||||
value: string | null,
|
||||
): Value {
|
||||
if (types.hasOwnProperty(key)) {
|
||||
const {type, nullable} = types[key];
|
||||
value = value === null ? '' : value;
|
||||
if (value.length <= 0 && nullable) {
|
||||
return {type: 'null', value: null};
|
||||
}
|
||||
|
||||
if (INT_DATA_TYPE.indexOf(type) >= 0) {
|
||||
const converted = parseInt(value, 10);
|
||||
return {type: 'integer', value: isNaN(converted) ? 0 : converted};
|
||||
} else if (FLOAT_DATA_TYPE.indexOf(type) >= 0) {
|
||||
const converted = parseFloat(value);
|
||||
return {type: 'float', value: isNaN(converted) ? 0 : converted};
|
||||
} else if (BLOB_DATA_TYPE.indexOf(type) >= 0) {
|
||||
return {type: 'blob', value};
|
||||
} else {
|
||||
return {type: 'string', value};
|
||||
}
|
||||
}
|
||||
// if no type found assume type is nullable string
|
||||
if (value === null || value.length <= 0) {
|
||||
return {type: 'null', value: null};
|
||||
} else {
|
||||
return {type: 'string', value};
|
||||
}
|
||||
}
|
||||
|
||||
export function constructQueryClause(
|
||||
values: {[key: string]: Value},
|
||||
connector: string,
|
||||
): string {
|
||||
return Object.entries(values).reduce(
|
||||
(clauses, [key, val]: [string, Value], idx) => {
|
||||
const valueString =
|
||||
val.type === 'null'
|
||||
? 'NULL'
|
||||
: val.type === 'string' || val.type === 'blob'
|
||||
? `'${val.value.replace(/'/g, "''")}'`
|
||||
: `${val.value}`;
|
||||
if (idx <= 0) {
|
||||
return `\`${key}\`=${valueString}`;
|
||||
} else {
|
||||
return `${clauses} ${connector} \`${key}\`=${valueString}`;
|
||||
}
|
||||
},
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
export function constructUpdateQuery(
|
||||
table: string,
|
||||
where: {[key: string]: Value},
|
||||
change: {[key: string]: Value},
|
||||
): string {
|
||||
return `UPDATE \`${table}\`
|
||||
SET ${constructQueryClause(change, ',')}
|
||||
WHERE ${constructQueryClause(where, 'AND')}`;
|
||||
}
|
||||
|
||||
export function isUpdatable(
|
||||
columnMeta: Array<string>,
|
||||
columnData: Array<Array<Value>>,
|
||||
): boolean {
|
||||
const primaryKeyIdx = columnMeta.indexOf('primary_key');
|
||||
return (
|
||||
primaryKeyIdx >= 0 &&
|
||||
columnData.reduce((acc: boolean, column) => {
|
||||
const primaryValue = column[primaryKeyIdx];
|
||||
return acc || (primaryValue.type === 'boolean' && primaryValue.value);
|
||||
}, false)
|
||||
);
|
||||
}
|
||||
@@ -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 {render, fireEvent} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
// TODO T71355623
|
||||
// eslint-disable-next-line flipper/no-relative-imports-across-packages
|
||||
import reducers, {Store} from '../../../../app/src/reducers';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import {Provider} from 'react-redux';
|
||||
|
||||
import {Value} from 'flipper';
|
||||
import DatabaseDetailSidebar from '../DatabaseDetailSidebar';
|
||||
|
||||
const labels: Array<string> = [
|
||||
'_id',
|
||||
'db1_col0_text',
|
||||
'db1_col1_integer',
|
||||
'db1_col2_float',
|
||||
'db1_col3_blob',
|
||||
'db1_col4_null',
|
||||
'db1_col5',
|
||||
'db1_col6',
|
||||
'db1_col7',
|
||||
'db1_col8',
|
||||
'db1_col9',
|
||||
];
|
||||
const values: Array<Value> = [
|
||||
{value: 1, type: 'integer'},
|
||||
{value: 'Long text data for testing resizing', type: 'string'},
|
||||
{value: 1000, type: 'integer'},
|
||||
{value: 1000.4650268554688, type: 'float'},
|
||||
{value: '\u0000\u0000\u0000\u0001\u0001\u0000\u0001\u0001', type: 'blob'},
|
||||
{value: null, type: 'null'},
|
||||
{value: 'db_1_column5_value', type: 'string'},
|
||||
{value: 'db_1_column6_value', type: 'string'},
|
||||
{value: 'db_1_column7_value', type: 'string'},
|
||||
{value: 'db_1_column8_value', type: 'string'},
|
||||
{value: 'db_1_column9_value', type: 'string'},
|
||||
];
|
||||
|
||||
const mockStore: Store = configureStore([])(
|
||||
reducers(undefined, {type: 'INIT'}),
|
||||
) as Store;
|
||||
|
||||
beforeEach(() => {
|
||||
mockStore.dispatch({type: 'rightSidebarAvailable', value: true});
|
||||
mockStore.dispatch({type: 'rightSidebarVisible', value: true});
|
||||
});
|
||||
|
||||
test('render and try to see if it renders properly', () => {
|
||||
const res = render(
|
||||
<Provider store={mockStore}>
|
||||
<div id="detailsSidebar">
|
||||
<DatabaseDetailSidebar columnLabels={labels} columnValues={values} />
|
||||
</div>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
for (const label of labels) {
|
||||
expect(res.queryAllByText(label).length).toBeGreaterThan(0);
|
||||
}
|
||||
for (const value of values) {
|
||||
if (value.type === 'blob') {
|
||||
continue;
|
||||
}
|
||||
const searchValue: string =
|
||||
value.type === 'null' ? 'NULL' : value.value.toString();
|
||||
expect(res.queryAllByText(searchValue).length).toBeGreaterThan(0);
|
||||
}
|
||||
// Edit, Save, Close buttons should not be shown because no onSave is provided
|
||||
expect(res.queryAllByText('Edit').length).toBe(0);
|
||||
expect(res.queryAllByText('Save').length).toBe(0);
|
||||
expect(res.queryAllByText('Close').length).toBe(0);
|
||||
});
|
||||
|
||||
test('render edit, save, and close correctly when onSave provided', () => {
|
||||
const res = render(
|
||||
<Provider store={mockStore}>
|
||||
<div id="detailsSidebar">
|
||||
<DatabaseDetailSidebar
|
||||
columnLabels={labels}
|
||||
columnValues={values}
|
||||
onSave={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// expect only Edit to show up
|
||||
expect(res.queryAllByText('Edit').length).toBe(1);
|
||||
expect(res.queryAllByText('Save').length).toBe(0);
|
||||
expect(res.queryAllByText('Close').length).toBe(0);
|
||||
|
||||
fireEvent.click(res.getByText('Edit'));
|
||||
// expect Save and Close to show up
|
||||
expect(res.queryAllByText('Edit').length).toBe(0);
|
||||
expect(res.queryAllByText('Save').length).toBe(1);
|
||||
expect(res.queryAllByText('Close').length).toBe(1);
|
||||
|
||||
// unclickable because none field has changed
|
||||
fireEvent.click(res.getByText('Save'));
|
||||
expect(res.queryAllByText('Edit').length).toBe(0);
|
||||
expect(res.queryAllByText('Save').length).toBe(1);
|
||||
expect(res.queryAllByText('Close').length).toBe(1);
|
||||
|
||||
// Click on close to return to the previous state
|
||||
fireEvent.click(res.getByText('Close'));
|
||||
expect(res.queryAllByText('Edit').length).toBe(1);
|
||||
expect(res.queryAllByText('Save').length).toBe(0);
|
||||
expect(res.queryAllByText('Close').length).toBe(0);
|
||||
});
|
||||
|
||||
test('editing some field after trigger Edit', async () => {
|
||||
const mockOnSave = jest.fn((_changes) => {});
|
||||
const res = render(
|
||||
<Provider store={mockStore}>
|
||||
<div id="detailsSidebar">
|
||||
<DatabaseDetailSidebar
|
||||
columnLabels={labels}
|
||||
columnValues={values}
|
||||
onSave={mockOnSave}
|
||||
/>
|
||||
</div>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
fireEvent.click(res.getByText('Edit'));
|
||||
// still find all values because it needs to show up
|
||||
for (const value of values) {
|
||||
const searchValue = value.value?.toString();
|
||||
expect(
|
||||
(value.type === 'null'
|
||||
? res.queryAllByPlaceholderText('NULL')
|
||||
: value.type === 'blob'
|
||||
? res.queryAllByText(searchValue!)
|
||||
: res.queryAllByDisplayValue(searchValue!)
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// expect the last one to contain value of 'db_1_column9_value'
|
||||
const textFields = await res.findAllByTestId('update-query-input');
|
||||
const lastTextField = textFields[textFields.length - 1];
|
||||
// add '_edited' to the back
|
||||
fireEvent.change(lastTextField, {
|
||||
target: {value: 'db_1_column9_value_edited'},
|
||||
});
|
||||
|
||||
// be able to click on Save
|
||||
fireEvent.click(res.getByText('Save'));
|
||||
expect(res.queryAllByText('Edit').length).toBe(1);
|
||||
expect(res.queryAllByText('Save').length).toBe(0);
|
||||
expect(res.queryAllByText('Close').length).toBe(0);
|
||||
|
||||
expect(mockOnSave.mock.calls.length).toBe(1);
|
||||
expect(mockOnSave.mock.calls[0][0]).toEqual({
|
||||
db1_col9: 'db_1_column9_value_edited',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 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 {Value} from 'flipper';
|
||||
import {
|
||||
isUpdatable,
|
||||
convertStringToValue,
|
||||
constructQueryClause,
|
||||
constructUpdateQuery,
|
||||
} from '../UpdateQueryUtil';
|
||||
|
||||
const dbColumnMeta: Array<string> = [
|
||||
'column_name',
|
||||
'data_type',
|
||||
'nullable',
|
||||
'default',
|
||||
'primary_key',
|
||||
'foreign_key',
|
||||
];
|
||||
// this is copied from table db1_first_table from db database1.db
|
||||
const db1FirstTableColumnData: Array<Array<Value>> = [
|
||||
[
|
||||
{value: '_id', type: 'string'},
|
||||
{value: 'INTEGER', type: 'string'},
|
||||
{value: true, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
{value: true, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
],
|
||||
[
|
||||
{value: 'db1_col0_text', type: 'string'},
|
||||
{value: 'TEXT', type: 'string'},
|
||||
{value: true, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
{value: false, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
],
|
||||
[
|
||||
{value: 'db1_col1_integer', type: 'string'},
|
||||
{value: 'INTEGER', type: 'string'},
|
||||
{value: true, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
{value: false, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
],
|
||||
[
|
||||
{value: 'db1_col2_float', type: 'string'},
|
||||
{value: 'FLOAT', type: 'string'},
|
||||
{value: true, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
{value: false, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
],
|
||||
[
|
||||
{value: 'db1_col3_blob', type: 'string'},
|
||||
{value: 'TEXT', type: 'string'},
|
||||
{value: true, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
{value: false, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
],
|
||||
[
|
||||
{value: 'db1_col4_null', type: 'string'},
|
||||
{value: 'TEXT', type: 'string'},
|
||||
{value: true, type: 'boolean'},
|
||||
{value: 'NULL', type: 'string'},
|
||||
{value: false, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
],
|
||||
[
|
||||
{value: 'db1_col5', type: 'string'},
|
||||
{value: 'TEXT', type: 'string'},
|
||||
{value: true, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
{value: false, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
],
|
||||
[
|
||||
{value: 'db1_col6', type: 'string'},
|
||||
{value: 'TEXT', type: 'string'},
|
||||
{value: true, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
{value: false, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
],
|
||||
[
|
||||
{value: 'db1_col7', type: 'string'},
|
||||
{value: 'TEXT', type: 'string'},
|
||||
{value: true, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
{value: false, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
],
|
||||
[
|
||||
{value: 'db1_col8', type: 'string'},
|
||||
{value: 'TEXT', type: 'string'},
|
||||
{value: true, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
{value: false, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
],
|
||||
[
|
||||
{value: 'db1_col9', type: 'string'},
|
||||
{value: 'TEXT', type: 'string'},
|
||||
{value: true, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
{value: false, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
],
|
||||
];
|
||||
// this is copied from table android_metadata from db database1.db
|
||||
const androidMetadataColumnData: Array<Array<Value>> = [
|
||||
[
|
||||
{value: 'locale', type: 'string'},
|
||||
{value: 'TEXT', type: 'string'},
|
||||
{value: true, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
{value: false, type: 'boolean'},
|
||||
{type: 'null', value: null},
|
||||
],
|
||||
];
|
||||
|
||||
test('convertStringToValue', () => {
|
||||
const allTypes: {[key: string]: {type: string; nullable: boolean}} = {
|
||||
nullableString: {type: 'STRING', nullable: true},
|
||||
nonNullString: {type: 'STRING', nullable: false},
|
||||
nullableInteger: {type: 'INTEGER', nullable: true},
|
||||
nonNullInteger: {type: 'INTEGER', nullable: false},
|
||||
nullableBlob: {type: 'BLOB', nullable: true},
|
||||
nonNullBlob: {type: 'BLOB', nullable: false},
|
||||
nullableReal: {type: 'REAL', nullable: true},
|
||||
nonNullReal: {type: 'REAL', nullable: false},
|
||||
};
|
||||
|
||||
const testcases: Array<{
|
||||
input: {key: string; value: string};
|
||||
output: Value;
|
||||
}> = [
|
||||
{
|
||||
input: {key: 'nullableString', value: 'this is a string'},
|
||||
output: {type: 'string', value: 'this is a string'},
|
||||
},
|
||||
{
|
||||
input: {key: 'nullableString', value: ''},
|
||||
output: {type: 'null', value: null},
|
||||
},
|
||||
{
|
||||
input: {key: 'nonNullString', value: 'this is a string'},
|
||||
output: {type: 'string', value: 'this is a string'},
|
||||
},
|
||||
{
|
||||
input: {key: 'nonNullString', value: ''},
|
||||
output: {type: 'string', value: ''},
|
||||
},
|
||||
{
|
||||
input: {key: 'nullableInteger', value: '1337'},
|
||||
output: {type: 'integer', value: 1337},
|
||||
},
|
||||
{
|
||||
input: {key: 'nullableInteger', value: ''},
|
||||
output: {type: 'null', value: null},
|
||||
},
|
||||
{
|
||||
input: {key: 'nonNullInteger', value: '1337'},
|
||||
output: {type: 'integer', value: 1337},
|
||||
},
|
||||
{
|
||||
input: {key: 'nonNullInteger', value: ''},
|
||||
output: {type: 'integer', value: 0},
|
||||
},
|
||||
{
|
||||
input: {key: 'nullableBlob', value: 'this is a blob'},
|
||||
output: {type: 'blob', value: 'this is a blob'},
|
||||
},
|
||||
{
|
||||
input: {key: 'nullableBlob', value: ''},
|
||||
output: {type: 'null', value: null},
|
||||
},
|
||||
{
|
||||
input: {key: 'nonNullBlob', value: 'this is a blob'},
|
||||
output: {type: 'blob', value: 'this is a blob'},
|
||||
},
|
||||
{
|
||||
input: {key: 'nonNullBlob', value: ''},
|
||||
output: {type: 'blob', value: ''},
|
||||
},
|
||||
{
|
||||
input: {key: 'nullableReal', value: '13.37'},
|
||||
output: {type: 'float', value: 13.37},
|
||||
},
|
||||
{
|
||||
input: {key: 'nullableReal', value: ''},
|
||||
output: {type: 'null', value: null},
|
||||
},
|
||||
{
|
||||
input: {key: 'nonNullReal', value: '13.37'},
|
||||
output: {type: 'float', value: 13.37},
|
||||
},
|
||||
{
|
||||
input: {key: 'nonNullReal', value: ''},
|
||||
output: {type: 'float', value: 0},
|
||||
},
|
||||
{
|
||||
input: {key: 'nonExistingType', value: 'this has no type'},
|
||||
output: {type: 'string', value: 'this has no type'},
|
||||
},
|
||||
{
|
||||
input: {key: 'nonExistingType', value: ''},
|
||||
output: {type: 'null', value: null},
|
||||
},
|
||||
];
|
||||
|
||||
for (const testcase of testcases) {
|
||||
expect(
|
||||
convertStringToValue(allTypes, testcase.input.key, testcase.input.value),
|
||||
).toEqual(testcase.output);
|
||||
}
|
||||
});
|
||||
|
||||
test('constructQueryClause with no value given', () => {
|
||||
expect(constructQueryClause({}, 'connecter')).toEqual('');
|
||||
});
|
||||
|
||||
test('constructQueryClause with exactly one string value', () => {
|
||||
expect(
|
||||
constructQueryClause(
|
||||
{key1: {type: 'string', value: 'this is a string'}},
|
||||
'connecter',
|
||||
),
|
||||
).toEqual(`\`key1\`='this is a string'`);
|
||||
});
|
||||
|
||||
test('constructQueryClause with exactly one integer value', () => {
|
||||
expect(
|
||||
constructQueryClause({key1: {type: 'integer', value: 1337}}, 'connecter'),
|
||||
).toEqual(`\`key1\`=1337`);
|
||||
});
|
||||
|
||||
test('constructQueryClause with exactly one null value', () => {
|
||||
expect(
|
||||
constructQueryClause({key1: {type: 'null', value: null}}, 'connecter'),
|
||||
).toEqual(`\`key1\`=NULL`);
|
||||
});
|
||||
|
||||
test("constructQueryClause with special character (single quote ('))", () => {
|
||||
expect(
|
||||
constructQueryClause(
|
||||
{key1: {type: 'string', value: "this is a 'single quote'"}},
|
||||
'connecter',
|
||||
),
|
||||
).toEqual(`\`key1\`='this is a ''single quote'''`);
|
||||
});
|
||||
|
||||
test('constructQueryClause with multiple value', () => {
|
||||
const values: {[key: string]: Value} = {
|
||||
key1: {type: 'string', value: 'this is a string'},
|
||||
key2: {type: 'null', value: null},
|
||||
key3: {type: 'float', value: 13.37},
|
||||
};
|
||||
|
||||
expect(constructQueryClause(values, 'connector')).toEqual(
|
||||
`\`key1\`='this is a string' connector \`key2\`=NULL connector \`key3\`=13.37`,
|
||||
);
|
||||
});
|
||||
|
||||
test('constructQueryClause with multiple value with single quotes mixed in string', () => {
|
||||
const values: {[key: string]: Value} = {
|
||||
key1: {type: 'string', value: `this is 'a' string`},
|
||||
key2: {type: 'null', value: null},
|
||||
key3: {type: 'float', value: 13.37},
|
||||
key4: {type: 'string', value: `there are single quotes 'here' and 'there'`},
|
||||
};
|
||||
|
||||
expect(constructQueryClause(values, 'connector')).toEqual(
|
||||
`\`key1\`='this is ''a'' string' connector \`key2\`=NULL connector \`key3\`=13.37 connector \`key4\`='there are single quotes ''here'' and ''there'''`,
|
||||
);
|
||||
});
|
||||
|
||||
test('constructUpdateQuery', () => {
|
||||
const setClause: {[key: string]: Value} = {
|
||||
key1: {type: 'string', value: 'this is a string'},
|
||||
key2: {type: 'null', value: null},
|
||||
key3: {type: 'float', value: 13.37},
|
||||
};
|
||||
const whereClause: {[key: string]: Value} = {
|
||||
key4: {type: 'number', value: 13371337},
|
||||
};
|
||||
expect(constructUpdateQuery('table_name', whereClause, setClause)).toEqual(
|
||||
`UPDATE \`table_name\`
|
||||
SET \`key1\`='this is a string' , \`key2\`=NULL , \`key3\`=13.37
|
||||
WHERE \`key4\`=13371337`,
|
||||
);
|
||||
});
|
||||
|
||||
test('isUpdatable with straightforward test with some are true', () => {
|
||||
const columnMeta = ['primary_key'];
|
||||
const columnData: Array<Array<Value>> = [
|
||||
[{type: 'boolean', value: true}],
|
||||
[{type: 'boolean', value: false}],
|
||||
[{type: 'boolean', value: false}],
|
||||
[{type: 'boolean', value: false}],
|
||||
[{type: 'boolean', value: false}],
|
||||
[{type: 'boolean', value: false}],
|
||||
];
|
||||
expect(isUpdatable(columnMeta, columnData)).toBe(true);
|
||||
});
|
||||
|
||||
test('isUpdatable with straightforward test with all are false', () => {
|
||||
const columnMeta = ['primary_key'];
|
||||
const columnData: Array<Array<Value>> = [
|
||||
[{type: 'boolean', value: false}],
|
||||
[{type: 'boolean', value: false}],
|
||||
[{type: 'boolean', value: false}],
|
||||
[{type: 'boolean', value: false}],
|
||||
];
|
||||
expect(isUpdatable(columnMeta, columnData)).toBe(false);
|
||||
});
|
||||
|
||||
test('isUpdate with regular use case with some are true', () => {
|
||||
const columnMeta = dbColumnMeta;
|
||||
const columnData: Array<Array<Value>> = db1FirstTableColumnData;
|
||||
expect(isUpdatable(columnMeta, columnData)).toBe(true);
|
||||
});
|
||||
|
||||
test('isUpdate with regular use case with all are false', () => {
|
||||
const columnMeta = dbColumnMeta;
|
||||
const columnData: Array<Array<Value>> = androidMetadataColumnData;
|
||||
expect(isUpdatable(columnMeta, columnData)).toBe(false);
|
||||
});
|
||||
1436
desktop/plugins/public/databases/index.tsx
Normal file
1436
desktop/plugins/public/databases/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
29
desktop/plugins/public/databases/package.json
Normal file
29
desktop/plugins/public/databases/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-databases",
|
||||
"id": "Databases",
|
||||
"version": "0.0.0",
|
||||
"title": "Databases",
|
||||
"icon": "internet",
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"dependencies": {
|
||||
"dateformat": "^4.5.1",
|
||||
"sql-formatter": "^2.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@types/dateformat": "^3.0.1",
|
||||
"@types/sql-formatter": "^2.3.0",
|
||||
"react-redux": "^7.2.3",
|
||||
"redux-mock-store": "^1.0.1"
|
||||
},
|
||||
"bugs": {
|
||||
"email": "oncall+flipper@xmail.facebook.com",
|
||||
"url": "https://fb.workplace.com/groups/flippersupport/"
|
||||
}
|
||||
}
|
||||
118
desktop/plugins/public/example/index.tsx
Normal file
118
desktop/plugins/public/example/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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() {
|
||||
if (this.client.isConnected) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
28
desktop/plugins/public/example/package.json
Normal file
28
desktop/plugins/public/example/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-example",
|
||||
"id": "Example",
|
||||
"version": "0.0.0",
|
||||
"description": "An example for a Flipper plugin",
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"title": "Example Plugin",
|
||||
"icon": "apps",
|
||||
"bugs": {
|
||||
"url": "https://fbflipper.com/"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "flipper-pkg lint",
|
||||
"build": "flipper-pkg bundle",
|
||||
"watch": "flipper-pkg bundle --watch",
|
||||
"prepack": "flipper-pkg lint && flipper-pkg bundle --production"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"flipper": "*",
|
||||
"flipper-pkg": "*"
|
||||
}
|
||||
}
|
||||
238
desktop/plugins/public/flipper-messages/index.tsx
Normal file
238
desktop/plugins/public/flipper-messages/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
|
||||
*/
|
||||
|
||||
import {
|
||||
Button,
|
||||
colors,
|
||||
DetailSidebar,
|
||||
FlexCenter,
|
||||
FlexColumn,
|
||||
FlipperPlugin,
|
||||
ManagedDataInspector,
|
||||
Panel,
|
||||
SearchableTable,
|
||||
styled,
|
||||
TableHighlightedRows,
|
||||
} from 'flipper';
|
||||
import React from 'react';
|
||||
|
||||
type MessageInfo = {
|
||||
device?: string;
|
||||
app: string;
|
||||
flipperInternalMethod?: string;
|
||||
plugin?: string;
|
||||
pluginMethod?: string;
|
||||
payload?: any;
|
||||
direction: 'toClient' | 'toFlipper';
|
||||
};
|
||||
|
||||
type MessageRow = {
|
||||
columns: {
|
||||
time: {
|
||||
value: string;
|
||||
};
|
||||
device: {
|
||||
value?: string;
|
||||
isFilterable: true;
|
||||
};
|
||||
app: {
|
||||
value: string;
|
||||
isFilterable: true;
|
||||
};
|
||||
internalMethod: {
|
||||
value?: string;
|
||||
isFilterable: true;
|
||||
};
|
||||
plugin: {
|
||||
value?: string;
|
||||
isFilterable: true;
|
||||
};
|
||||
pluginMethod: {
|
||||
value?: string;
|
||||
isFilterable: true;
|
||||
};
|
||||
direction: {
|
||||
value: string;
|
||||
isFilterable: true;
|
||||
};
|
||||
};
|
||||
timestamp: number;
|
||||
payload?: any;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
selectedId: string | null;
|
||||
};
|
||||
|
||||
type PersistedState = {
|
||||
messageRows: Array<MessageRow>;
|
||||
};
|
||||
|
||||
const Placeholder = styled(FlexCenter)({
|
||||
fontSize: 18,
|
||||
color: colors.macOSTitleBarIcon,
|
||||
});
|
||||
|
||||
const COLUMNS = {
|
||||
time: {
|
||||
value: 'Time',
|
||||
},
|
||||
device: {
|
||||
value: 'Device',
|
||||
},
|
||||
app: {
|
||||
value: 'App',
|
||||
},
|
||||
internalMethod: {
|
||||
value: 'Flipper internal method',
|
||||
},
|
||||
plugin: {
|
||||
value: 'Plugin',
|
||||
},
|
||||
pluginMethod: {
|
||||
value: 'Method',
|
||||
},
|
||||
direction: {
|
||||
value: 'Direction',
|
||||
},
|
||||
};
|
||||
|
||||
const COLUMN_SIZES = {
|
||||
time: 'flex',
|
||||
device: 'flex',
|
||||
app: 'flex',
|
||||
internalMethod: 'flex',
|
||||
plugin: 'flex',
|
||||
pluginMethod: 'flex',
|
||||
direction: 'flex',
|
||||
};
|
||||
|
||||
let rowId = 0;
|
||||
|
||||
function createRow(message: MessageInfo): MessageRow {
|
||||
return {
|
||||
columns: {
|
||||
time: {
|
||||
value: new Date().toLocaleTimeString(),
|
||||
},
|
||||
device: {
|
||||
value: message.device,
|
||||
isFilterable: true,
|
||||
},
|
||||
app: {
|
||||
value: message.app,
|
||||
isFilterable: true,
|
||||
},
|
||||
internalMethod: {
|
||||
value: message.flipperInternalMethod,
|
||||
isFilterable: true,
|
||||
},
|
||||
plugin: {
|
||||
value: message.plugin,
|
||||
isFilterable: true,
|
||||
},
|
||||
pluginMethod: {
|
||||
value: message.pluginMethod,
|
||||
isFilterable: true,
|
||||
},
|
||||
direction: {
|
||||
value: message.direction,
|
||||
isFilterable: true,
|
||||
},
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
payload: message.payload,
|
||||
key: '' + rowId++,
|
||||
};
|
||||
}
|
||||
|
||||
export default class extends FlipperPlugin<State, any, PersistedState> {
|
||||
static defaultPersistedState = {
|
||||
messageRows: [],
|
||||
};
|
||||
|
||||
state: State = {
|
||||
selectedId: null,
|
||||
};
|
||||
|
||||
static persistedStateReducer = (
|
||||
persistedState: PersistedState,
|
||||
method: string,
|
||||
payload: any,
|
||||
): PersistedState => {
|
||||
if (method === 'newMessage') {
|
||||
return {
|
||||
...persistedState,
|
||||
messageRows: [...persistedState.messageRows, createRow(payload)].filter(
|
||||
(row) => Date.now() - row.timestamp < 5 * 60 * 1000,
|
||||
),
|
||||
};
|
||||
}
|
||||
return persistedState;
|
||||
};
|
||||
|
||||
render() {
|
||||
const clearTableButton = (
|
||||
<Button onClick={this.clear} key="clear">
|
||||
Clear Table
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlexColumn grow={true}>
|
||||
<SearchableTable
|
||||
rowLineHeight={28}
|
||||
floating={false}
|
||||
multiline={true}
|
||||
columnSizes={COLUMN_SIZES}
|
||||
columns={COLUMNS}
|
||||
onRowHighlighted={this.onRowHighlighted}
|
||||
rows={this.props.persistedState.messageRows}
|
||||
stickyBottom={true}
|
||||
actions={[clearTableButton]}
|
||||
/>
|
||||
<DetailSidebar>{this.renderSidebar()}</DetailSidebar>
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
|
||||
onRowHighlighted = (keys: TableHighlightedRows) => {
|
||||
if (keys.length > 0) {
|
||||
this.setState({
|
||||
selectedId: keys[0],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
renderSidebar() {
|
||||
const {selectedId} = this.state;
|
||||
const {messageRows} = this.props.persistedState;
|
||||
if (selectedId !== null) {
|
||||
const message = messageRows.find((row) => row.key == selectedId);
|
||||
if (message != null) {
|
||||
return this.renderExtra(message.payload);
|
||||
}
|
||||
}
|
||||
return <Placeholder grow>Select a message to view details</Placeholder>;
|
||||
}
|
||||
|
||||
renderExtra(extra: any) {
|
||||
return (
|
||||
<Panel floating={false} grow={false} heading={'Payload'}>
|
||||
<ManagedDataInspector data={extra} expandRoot={false} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
clear = () => {
|
||||
this.setState({selectedId: null});
|
||||
this.props.setPersistedState({messageRows: []});
|
||||
};
|
||||
}
|
||||
28
desktop/plugins/public/flipper-messages/package.json
Normal file
28
desktop/plugins/public/flipper-messages/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-flipper-messages",
|
||||
"id": "flipper-messages",
|
||||
"title": "Flipper Messages",
|
||||
"icon": "bird",
|
||||
"version": "0.0.0",
|
||||
"description": "Flipper self inspection: Messages to and from client",
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"bugs": {
|
||||
"url": "https://fbflipper.com/"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "flipper-pkg lint",
|
||||
"build": "flipper-pkg bundle",
|
||||
"watch": "flipper-pkg bundle --watch",
|
||||
"prepack": "flipper-pkg lint && flipper-pkg bundle --production"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"flipper": "*",
|
||||
"flipper-pkg": "*"
|
||||
}
|
||||
}
|
||||
78
desktop/plugins/public/fresco/ImagePool.tsx
Normal file
78
desktop/plugins/public/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());
|
||||
};
|
||||
}
|
||||
513
desktop/plugins/public/fresco/ImagesCacheOverview.tsx
Normal file
513
desktop/plugins/public/fresco/ImagesCacheOverview.tsx
Normal file
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* 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,
|
||||
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)(() => ({
|
||||
alignSelf: 'center',
|
||||
marginRight: 4,
|
||||
minWidth: 30,
|
||||
}));
|
||||
|
||||
const ToggleLabel = styled(Text)(() => ({
|
||||
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;
|
||||
onShowDiskImages: (enabled: boolean) => void;
|
||||
showDiskImages: boolean;
|
||||
};
|
||||
|
||||
type ImagesCacheOverviewState = {
|
||||
selectedImage: ImageId | null;
|
||||
size: number;
|
||||
};
|
||||
|
||||
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"
|
||||
/>
|
||||
<Toggle
|
||||
toggled={this.props.showDiskImages}
|
||||
onClick={this.props.onShowDiskImages}
|
||||
label="Show Disk Images"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
189
desktop/plugins/public/fresco/ImagesSidebar.tsx
Normal file
189
desktop/plugins/public/fresco/ImagesSidebar.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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={null}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<DataDescriptionKey>Time end</DataDescriptionKey>
|
||||
<span key="sep">: </span>
|
||||
<DataDescription
|
||||
type="number"
|
||||
value={event.endTime}
|
||||
setValue={null}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<DataDescriptionKey>Source</DataDescriptionKey>
|
||||
<span key="sep">: </span>
|
||||
<DataDescription type="string" value={event.source} setValue={null} />
|
||||
</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={null}
|
||||
/>
|
||||
</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/public/fresco/MultipleSelect.tsx
Normal file
111
desktop/plugins/public/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>
|
||||
`;
|
||||
99
desktop/plugins/public/fresco/__tests__/index.node.tsx
Normal file
99
desktop/plugins/public/fresco/__tests__/index.node.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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 {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,
|
||||
showDiskImages: false,
|
||||
};
|
||||
}
|
||||
|
||||
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/public/fresco/api.tsx
Normal file
77
desktop/plugins/public/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;
|
||||
};
|
||||
495
desktop/plugins/public/fresco/index.tsx
Normal file
495
desktop/plugins/public/fresco/index.tsx
Normal file
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* 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 {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;
|
||||
showDiskImages: 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);
|
||||
}
|
||||
};
|
||||
|
||||
export default class FlipperImagesPlugin extends FlipperPlugin<
|
||||
PluginState,
|
||||
BaseAction,
|
||||
PersistedState
|
||||
> {
|
||||
static defaultPersistedState: PersistedState = {
|
||||
images: [],
|
||||
events: [],
|
||||
imagesMap: {},
|
||||
surfaceList: new Set(),
|
||||
closeableReferenceLeaks: [],
|
||||
isLeakTrackingEnabled: false,
|
||||
showDiskImages: false,
|
||||
nextEventId: 0,
|
||||
};
|
||||
|
||||
static exportPersistedState = (
|
||||
callClient: undefined | ((method: string, params?: any) => Promise<any>),
|
||||
persistedState: PersistedState,
|
||||
store?: ReduxState,
|
||||
): Promise<PersistedState> => {
|
||||
const defaultPromise = Promise.resolve(persistedState);
|
||||
if (!persistedState) {
|
||||
persistedState = FlipperImagesPlugin.defaultPersistedState;
|
||||
}
|
||||
if (!store || !callClient) {
|
||||
return defaultPromise;
|
||||
}
|
||||
return Promise.all([
|
||||
callClient('listImages', {showDiskImages: persistedState.showDiskImages}),
|
||||
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 = new Set([
|
||||
...pluginData.surfaceList,
|
||||
surface,
|
||||
]);
|
||||
}
|
||||
}
|
||||
pluginData = {
|
||||
...pluginData,
|
||||
events: [{...event, eventId: index}, ...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);
|
||||
let {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 = new Set([...surfaceList, surface]);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...persistedState,
|
||||
surfaceList,
|
||||
events: [
|
||||
{eventId: persistedState.nextEventId, ...event},
|
||||
...persistedState.events,
|
||||
],
|
||||
nextEventId: persistedState.nextEventId + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return persistedState;
|
||||
};
|
||||
|
||||
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()');
|
||||
if (this.client.isConnected) {
|
||||
this.updateCaches('init');
|
||||
this.client.subscribe(
|
||||
'debug_overlay_event',
|
||||
(event: FrescoDebugOverlayEvent) => {
|
||||
this.setState({isDebugOverlayEnabled: event.enabled});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
debugLog(`not connected)`);
|
||||
}
|
||||
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', {
|
||||
showDiskImages: this.props.persistedState.showDiskImages,
|
||||
})
|
||||
.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) => {
|
||||
if (!this.client.isConnected) {
|
||||
debugLog(`Cannot fetch image ${imageId}: disconnected`);
|
||||
return;
|
||||
}
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
onShowDiskImages = (checked: boolean) => {
|
||||
this.props.logger.track('usage', 'fresco:onShowDiskImages', {
|
||||
enabled: checked,
|
||||
});
|
||||
this.props.setPersistedState({
|
||||
showDiskImages: checked,
|
||||
});
|
||||
this.updateCaches('refresh');
|
||||
};
|
||||
|
||||
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}
|
||||
showDiskImages={this.props.persistedState.showDiskImages}
|
||||
onShowDiskImages={this.onShowDiskImages}
|
||||
/>
|
||||
<DetailSidebar>{this.renderSidebar()}</DetailSidebar>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
17
desktop/plugins/public/fresco/package.json
Normal file
17
desktop/plugins/public/fresco/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-fresco",
|
||||
"id": "Fresco",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"title": "Images",
|
||||
"icon": "profile",
|
||||
"bugs": {
|
||||
"email": "oncall+fresco@xmail.facebook.com"
|
||||
}
|
||||
}
|
||||
80
desktop/plugins/public/hermesdebuggerrn/Banner.tsx
Normal file
80
desktop/plugins/public/hermesdebuggerrn/Banner.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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 {shell} from 'electron';
|
||||
import {styled, colors, FlexRow, Text, GK} from 'flipper';
|
||||
|
||||
const BannerContainer = styled(FlexRow)({
|
||||
height: '30px',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#2bb673', // Hermes green.
|
||||
});
|
||||
|
||||
const BannerText = styled(Text)({
|
||||
color: colors.white,
|
||||
fontSize: 14,
|
||||
lineHeight: '20px',
|
||||
});
|
||||
|
||||
const BannerLink = styled(CustomLink)({
|
||||
color: colors.white,
|
||||
textDecoration: 'underline',
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
color: '#303846',
|
||||
},
|
||||
});
|
||||
|
||||
const StyledLink = styled.span({
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
});
|
||||
|
||||
StyledLink.displayName = 'CustomLink:StyledLink';
|
||||
|
||||
function CustomLink(props: {
|
||||
href: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<StyledLink
|
||||
className={props.className}
|
||||
onClick={() => shell.openExternal(props.href)}
|
||||
style={props.style}>
|
||||
{props.children || props.href}
|
||||
</StyledLink>
|
||||
);
|
||||
}
|
||||
|
||||
export const isBannerEnabled: () => boolean = function () {
|
||||
return GK.get('flipper_plugin_hermes_debugger_survey');
|
||||
};
|
||||
|
||||
export default function Banner() {
|
||||
if (!GK.get('flipper_plugin_hermes_debugger_survey')) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<BannerContainer>
|
||||
<BannerText>
|
||||
Help us improve your debugging experience with this{' '}
|
||||
<BannerLink href="https://fburl.com/hermessurvey">
|
||||
single page survey
|
||||
</BannerLink>
|
||||
!
|
||||
</BannerText>
|
||||
</BannerContainer>
|
||||
);
|
||||
}
|
||||
120
desktop/plugins/public/hermesdebuggerrn/ChromeDevTools.tsx
Normal file
120
desktop/plugins/public/hermesdebuggerrn/ChromeDevTools.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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 {styled, colors, FlexColumn} from 'flipper';
|
||||
|
||||
import electron from 'electron';
|
||||
|
||||
const devToolsNodeId = (url: string) =>
|
||||
`hermes-chromedevtools-out-of-react-node-${url.replace(/\W+/g, '-')}`;
|
||||
|
||||
// TODO: build abstraction of this: T62306732
|
||||
const TARGET_CONTAINER_ID = 'flipper-out-of-contents-container'; // should be a hook in the future
|
||||
|
||||
function createDevToolsNode(
|
||||
url: string,
|
||||
marginTop: string | null,
|
||||
): HTMLElement {
|
||||
const existing = findDevToolsNode(url);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// It is necessary to activate chrome devtools in electron
|
||||
electron.remote.getCurrentWindow().webContents.toggleDevTools();
|
||||
electron.remote.getCurrentWindow().webContents.closeDevTools();
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = devToolsNodeId(url);
|
||||
wrapper.style.height = '100%';
|
||||
wrapper.style.width = '100%';
|
||||
|
||||
const iframe = document.createElement('webview');
|
||||
iframe.style.height = '100%';
|
||||
iframe.style.width = '100%';
|
||||
|
||||
// HACK: chrome-devtools:// is blocked by the sandbox but devtools:// isn't for some reason.
|
||||
iframe.src = url.replace(/^chrome-/, '');
|
||||
|
||||
wrapper.appendChild(iframe);
|
||||
|
||||
if (marginTop) {
|
||||
document.getElementById(TARGET_CONTAINER_ID)!.style.marginTop = marginTop;
|
||||
}
|
||||
|
||||
document.getElementById(TARGET_CONTAINER_ID)!.appendChild(wrapper);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function findDevToolsNode(url: string): HTMLElement | null {
|
||||
return document.querySelector('#' + devToolsNodeId(url));
|
||||
}
|
||||
|
||||
function attachDevTools(devToolsNode: HTMLElement) {
|
||||
devToolsNode.style.display = 'block';
|
||||
document.getElementById(TARGET_CONTAINER_ID)!.style.display = 'block';
|
||||
document.getElementById(TARGET_CONTAINER_ID)!.parentElement!.style.display =
|
||||
'block';
|
||||
}
|
||||
|
||||
function detachDevTools(devToolsNode: HTMLElement | null) {
|
||||
document.getElementById(TARGET_CONTAINER_ID)!.style.display = 'none';
|
||||
document.getElementById(TARGET_CONTAINER_ID)!.parentElement!.style.display =
|
||||
'none';
|
||||
|
||||
if (devToolsNode) {
|
||||
devToolsNode.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const EmptyContainer = styled(FlexColumn)({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.light02,
|
||||
});
|
||||
|
||||
type ChromeDevToolsProps = {
|
||||
url: string;
|
||||
marginTop: string | null;
|
||||
};
|
||||
|
||||
export default class ChromeDevTools extends React.Component<ChromeDevToolsProps> {
|
||||
createDevTools(url: string, marginTop: string | null) {
|
||||
const devToolsNode = createDevToolsNode(url, marginTop);
|
||||
attachDevTools(devToolsNode);
|
||||
}
|
||||
|
||||
hideDevTools(_url: string) {
|
||||
detachDevTools(findDevToolsNode(this.props.url));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.createDevTools(this.props.url, this.props.marginTop);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.hideDevTools(this.props.url);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: ChromeDevToolsProps) {
|
||||
const oldUrl = prevProps.url;
|
||||
const newUrl = this.props.url;
|
||||
if (oldUrl != newUrl) {
|
||||
this.hideDevTools(oldUrl);
|
||||
this.createDevTools(newUrl, this.props.marginTop);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <EmptyContainer />;
|
||||
}
|
||||
}
|
||||
108
desktop/plugins/public/hermesdebuggerrn/ErrorScreen.tsx
Normal file
108
desktop/plugins/public/hermesdebuggerrn/ErrorScreen.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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 {styled, FlexColumn, FlexRow, Text, Glyph, colors} from 'flipper';
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.light02,
|
||||
});
|
||||
|
||||
const Welcome = styled(FlexColumn)({
|
||||
width: 460,
|
||||
background: colors.white,
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
|
||||
overflow: 'hidden',
|
||||
transition: '0.6s all ease-out',
|
||||
});
|
||||
|
||||
const Title = styled(Text)({
|
||||
fontSize: 24,
|
||||
fontWeight: 300,
|
||||
textAlign: 'center',
|
||||
color: colors.light50,
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
});
|
||||
|
||||
const Item = styled(FlexRow)({
|
||||
padding: 10,
|
||||
alignItems: 'center',
|
||||
borderTop: `1px solid ${colors.light10}`,
|
||||
});
|
||||
|
||||
const ItemTitle = styled(Text)({
|
||||
color: colors.light50,
|
||||
fontSize: 14,
|
||||
lineHeight: '20px',
|
||||
});
|
||||
|
||||
const Bold = styled(Text)({
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
const Icon = styled(Glyph)({
|
||||
marginRight: 11,
|
||||
marginLeft: 6,
|
||||
});
|
||||
|
||||
// As more known failures are found, add them to this list with better error information.
|
||||
const KNOWN_FAILURE_MESSAGES: Record<
|
||||
string,
|
||||
Record<'message' | 'hint', string>
|
||||
> = {
|
||||
'Failed to fetch': {
|
||||
// This is the error that is returned specifcally when Metro is turned off.
|
||||
message: 'Metro disconnected.',
|
||||
hint: 'Please check that metro is running and Flipper can connect to it.',
|
||||
},
|
||||
default: {
|
||||
// All we really know in this case is that we can't connect to metro.
|
||||
// Do not try and be more specific here.
|
||||
message: 'Cannot connect to Metro.',
|
||||
hint: 'Please check that metro is running and Flipper can connect to it.',
|
||||
},
|
||||
};
|
||||
|
||||
function getReason(error: Error) {
|
||||
let failure_message = KNOWN_FAILURE_MESSAGES.default;
|
||||
if (error != null && KNOWN_FAILURE_MESSAGES[error.message]) {
|
||||
failure_message = KNOWN_FAILURE_MESSAGES[error.message];
|
||||
}
|
||||
|
||||
return (
|
||||
<ItemTitle>
|
||||
<Bold>{failure_message.message} </Bold>
|
||||
{failure_message.hint}
|
||||
</ItemTitle>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = Readonly<{
|
||||
error: Error;
|
||||
}>;
|
||||
|
||||
export default function ErrorScreen(props: Props) {
|
||||
return (
|
||||
<Container>
|
||||
<Welcome>
|
||||
<Title>Hermes Debugger Error</Title>
|
||||
<Item>
|
||||
<Icon size={20} name="caution-octagon" color={colors.red} />
|
||||
<FlexColumn>{getReason(props.error)}</FlexColumn>
|
||||
</Item>
|
||||
</Welcome>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
79
desktop/plugins/public/hermesdebuggerrn/LaunchScreen.tsx
Normal file
79
desktop/plugins/public/hermesdebuggerrn/LaunchScreen.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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 {styled, FlexColumn, FlexRow, Text, Glyph, colors} from 'flipper';
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.light02,
|
||||
});
|
||||
|
||||
const Welcome = styled(FlexColumn)({
|
||||
width: 460,
|
||||
background: colors.white,
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
|
||||
overflow: 'hidden',
|
||||
transition: '0.6s all ease-out',
|
||||
});
|
||||
|
||||
const Title = styled(Text)({
|
||||
fontSize: 24,
|
||||
fontWeight: 300,
|
||||
textAlign: 'center',
|
||||
color: colors.light50,
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
});
|
||||
|
||||
const Item = styled(FlexRow)({
|
||||
padding: 10,
|
||||
alignItems: 'center',
|
||||
borderTop: `1px solid ${colors.light10}`,
|
||||
});
|
||||
|
||||
const ItemTitle = styled(Text)({
|
||||
color: colors.light50,
|
||||
fontSize: 14,
|
||||
lineHeight: '20px',
|
||||
});
|
||||
|
||||
const Bold = styled(Text)({
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
const Icon = styled(Glyph)({
|
||||
marginRight: 11,
|
||||
marginLeft: 6,
|
||||
});
|
||||
|
||||
export default function LaunchScreen() {
|
||||
return (
|
||||
<Container>
|
||||
<Welcome>
|
||||
<Title>Hermes Debugger</Title>
|
||||
<Item>
|
||||
<Icon size={20} name="question-circle" color={colors.info} />
|
||||
<FlexColumn>
|
||||
<ItemTitle>
|
||||
<Bold>Metro is connected but no Hermes apps were found.</Bold>{' '}
|
||||
Open a React Native screen with Hermes enabled to connect. Note:
|
||||
you may need to reload the app in order to reconnect the device to
|
||||
Metro.
|
||||
</ItemTitle>
|
||||
</FlexColumn>
|
||||
</Item>
|
||||
</Welcome>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
85
desktop/plugins/public/hermesdebuggerrn/SelectScreen.tsx
Normal file
85
desktop/plugins/public/hermesdebuggerrn/SelectScreen.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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 {styled, FlexColumn, FlexRow, Text, Glyph, colors} from 'flipper';
|
||||
import {Target, Targets} from './index';
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.light02,
|
||||
});
|
||||
|
||||
const Welcome = styled(FlexColumn)({
|
||||
width: 460,
|
||||
background: colors.white,
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
|
||||
overflow: 'hidden',
|
||||
transition: '0.6s all ease-out',
|
||||
});
|
||||
|
||||
const Title = styled(Text)({
|
||||
fontSize: 24,
|
||||
fontWeight: 300,
|
||||
textAlign: 'center',
|
||||
color: colors.light50,
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
});
|
||||
|
||||
const Item = styled(FlexRow)({
|
||||
padding: 10,
|
||||
alignItems: 'center',
|
||||
borderTop: `1px solid ${colors.light10}`,
|
||||
});
|
||||
|
||||
const ItemTitle = styled(Text)({
|
||||
color: colors.light50,
|
||||
fontSize: 14,
|
||||
lineHeight: '20px',
|
||||
});
|
||||
|
||||
const Icon = styled(Glyph)({
|
||||
marginRight: 11,
|
||||
marginLeft: 6,
|
||||
});
|
||||
|
||||
type Props = {
|
||||
readonly targets: Targets;
|
||||
readonly onSelect: (target: Target) => void;
|
||||
};
|
||||
|
||||
export default function SelectScreen(props: Props) {
|
||||
return (
|
||||
<Container>
|
||||
<Welcome>
|
||||
<Title>Hermes Debugger Select</Title>
|
||||
<Item>
|
||||
<FlexColumn>
|
||||
<ItemTitle>Please select a target:</ItemTitle>
|
||||
</FlexColumn>
|
||||
</Item>
|
||||
{props.targets.map((target) => {
|
||||
return (
|
||||
<Item onClick={() => props.onSelect(target)} key={target.id}>
|
||||
<Icon size={20} name="code" color={colors.info} />
|
||||
<FlexColumn>
|
||||
<ItemTitle>{target.title}</ItemTitle>
|
||||
</FlexColumn>
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
</Welcome>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
177
desktop/plugins/public/hermesdebuggerrn/index.tsx
Normal file
177
desktop/plugins/public/hermesdebuggerrn/index.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 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,
|
||||
styled,
|
||||
colors,
|
||||
FlexRow,
|
||||
FlexColumn,
|
||||
} from 'flipper';
|
||||
import LaunchScreen from './LaunchScreen';
|
||||
import Banner, {isBannerEnabled} from './Banner';
|
||||
import SelectScreen from './SelectScreen';
|
||||
import ErrorScreen from './ErrorScreen';
|
||||
import ChromeDevTools from './ChromeDevTools';
|
||||
|
||||
const POLL_SECS = 5 * 1000;
|
||||
const METRO_PORT_ENV_VAR = process.env.METRO_SERVER_PORT || '8081';
|
||||
const METRO_PORT = isNaN(+METRO_PORT_ENV_VAR) ? '8081' : METRO_PORT_ENV_VAR;
|
||||
const METRO_URL = new URL('http://localhost');
|
||||
METRO_URL.port = METRO_PORT;
|
||||
|
||||
export type Target = Readonly<{
|
||||
id: string;
|
||||
description: string;
|
||||
title: string;
|
||||
faviconUrl: string;
|
||||
devtoolsFrontendUrl: string;
|
||||
type: string;
|
||||
webSocketDebuggerUrl: string;
|
||||
vm: string;
|
||||
}>;
|
||||
|
||||
export type Targets = ReadonlyArray<Target>;
|
||||
|
||||
type State = Readonly<{
|
||||
targets?: Targets | null;
|
||||
selectedTarget?: Target | null;
|
||||
error?: Error | null;
|
||||
}>;
|
||||
|
||||
const Content = styled(FlexRow)({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: colors.light02,
|
||||
});
|
||||
|
||||
export default class extends FlipperDevicePlugin<State, any, any> {
|
||||
static supportsDevice(device: Device) {
|
||||
return !device.isArchived && device.os === 'Metro';
|
||||
}
|
||||
|
||||
state: State = {
|
||||
targets: null,
|
||||
selectedTarget: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
poll?: NodeJS.Timeout;
|
||||
|
||||
componentDidMount() {
|
||||
// This is a pretty basic polling mechnaism. We ask Metro every POLL_SECS what the
|
||||
// current available targets are and only handle a few basic state transitions.
|
||||
this.poll = setInterval(this.checkDebugTargets, POLL_SECS);
|
||||
this.checkDebugTargets();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.poll) {
|
||||
clearInterval(this.poll);
|
||||
}
|
||||
}
|
||||
|
||||
checkDebugTargets = () => {
|
||||
fetch(`${METRO_URL.toString()}json`)
|
||||
.then((res) => res.json())
|
||||
.then(
|
||||
(result) => {
|
||||
// We only want to use the Chrome Reload targets.
|
||||
const targets = result.filter(
|
||||
(target: any) =>
|
||||
target.title ===
|
||||
'React Native Experimental (Improved Chrome Reloads)',
|
||||
);
|
||||
|
||||
// Find the currently selected target.
|
||||
// If the current selectedTarget isn't returned, clear it.
|
||||
let currentlySelected = null;
|
||||
if (this.state.selectedTarget != null) {
|
||||
for (const target of result) {
|
||||
if (
|
||||
this.state.selectedTarget?.webSocketDebuggerUrl ===
|
||||
target.webSocketDebuggerUrl
|
||||
) {
|
||||
currentlySelected = this.state.selectedTarget;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select the first target if there is one,
|
||||
// but don't change the one that's already selected.
|
||||
const selectedTarget =
|
||||
currentlySelected == null && targets.length === 1
|
||||
? targets[0]
|
||||
: currentlySelected;
|
||||
|
||||
this.setState({
|
||||
error: null,
|
||||
targets,
|
||||
selectedTarget,
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
this.setState({
|
||||
targets: null,
|
||||
selectedTarget: null,
|
||||
error,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
handleSelect = (selectedTarget: Target) => this.setState({selectedTarget});
|
||||
|
||||
renderContent() {
|
||||
const {error, selectedTarget, targets} = this.state;
|
||||
|
||||
if (selectedTarget) {
|
||||
let bannerMargin = null;
|
||||
if (isBannerEnabled()) {
|
||||
bannerMargin = '29px';
|
||||
}
|
||||
|
||||
return (
|
||||
<ChromeDevTools
|
||||
url={selectedTarget.devtoolsFrontendUrl}
|
||||
marginTop={bannerMargin}
|
||||
/>
|
||||
);
|
||||
} else if (targets != null && targets.length === 0) {
|
||||
return <LaunchScreen />;
|
||||
} else if (targets != null && targets.length > 0) {
|
||||
return <SelectScreen targets={targets} onSelect={this.handleSelect} />;
|
||||
} else if (error != null) {
|
||||
return <ErrorScreen error={error} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Container>
|
||||
<Banner />
|
||||
<Content>{this.renderContent()}</Content>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
21
desktop/plugins/public/hermesdebuggerrn/package.json
Normal file
21
desktop/plugins/public/hermesdebuggerrn/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-hermesdebuggerrn",
|
||||
"id": "Hermesdebuggerrn",
|
||||
"pluginType": "device",
|
||||
"supportedDevices": [
|
||||
{"os": "Metro", "archived": false}
|
||||
],
|
||||
"version": "0.0.0",
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"license": "MIT",
|
||||
"title": "Hermes Debugger (RN)",
|
||||
"icon": "code",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"bugs": {
|
||||
"email": "rickhanlonii@fb.com"
|
||||
}
|
||||
}
|
||||
443
desktop/plugins/public/kaios-allocations/index.tsx
Normal file
443
desktop/plugins/public/kaios-allocations/index.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
35
desktop/plugins/public/kaios-allocations/package.json
Normal file
35
desktop/plugins/public/kaios-allocations/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-kaios-big-allocations",
|
||||
"id": "kaios-big-allocations",
|
||||
"version": "0.0.0",
|
||||
"pluginType": "device",
|
||||
"supportedDevices": [
|
||||
{"os": "Android", "specs": ["KaiOS"]}
|
||||
],
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"license": "MIT",
|
||||
"title": "KaiOS: big allocations",
|
||||
"icon": "apps",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"firefox-client": "0.3.0",
|
||||
"promisify-child-process": "^4.1.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"minimist": "1.2.3"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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' {
|
||||
export default function extend(prototype: any, o: any): any;
|
||||
}
|
||||
282
desktop/plugins/public/kaios-ram/index.tsx
Normal file
282
desktop/plugins/public/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(/\D+/)) {
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
37
desktop/plugins/public/kaios-ram/package.json
Normal file
37
desktop/plugins/public/kaios-ram/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-kaios-graphs",
|
||||
"id": "kaios-graphs",
|
||||
"version": "0.0.0",
|
||||
"pluginType": "device",
|
||||
"supportedDevices": [
|
||||
{
|
||||
"os": "Android",
|
||||
"specs": [
|
||||
"KaiOS"
|
||||
]
|
||||
}
|
||||
],
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"license": "MIT",
|
||||
"title": "KaiOS RAM graph",
|
||||
"icon": "apps",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"bugs": {
|
||||
"email": "oncall+wa_kaios@xmail.facebook.com",
|
||||
"url": "https://fb.workplace.com/groups/wa.kaios/"
|
||||
},
|
||||
"dependencies": {
|
||||
"promisify-child-process": "^4.1.0",
|
||||
"recharts": "2.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/recharts": "1.8.19"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "16.9.17"
|
||||
}
|
||||
}
|
||||
474
desktop/plugins/public/layout/Inspector.tsx
Normal file
474
desktop/plugins/public/layout/Inspector.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
ElementID,
|
||||
Element,
|
||||
PluginClient,
|
||||
ElementsInspector,
|
||||
ElementSearchResultSet,
|
||||
FlexColumn,
|
||||
styled,
|
||||
} from 'flipper';
|
||||
import {debounce} from 'lodash';
|
||||
import {Component} from 'react';
|
||||
import {PersistedState, ElementMap} from './';
|
||||
import React from 'react';
|
||||
import MultipleSelectorSection from './MultipleSelectionSection';
|
||||
|
||||
const ElementsInspectorContainer = styled(FlexColumn)({
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
type GetNodesOptions = {
|
||||
force?: boolean;
|
||||
ax?: boolean;
|
||||
forAccessibilityEvent?: boolean;
|
||||
};
|
||||
|
||||
export type ElementSelectorNode = {[id: string]: ElementSelectorNode};
|
||||
export type ElementSelectorData = {
|
||||
leaves: Array<ElementID>;
|
||||
tree: ElementSelectorNode;
|
||||
elements: ElementMap;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
ax?: boolean;
|
||||
client: PluginClient;
|
||||
showsSidebar: boolean;
|
||||
inAlignmentMode?: boolean;
|
||||
selectedElement: ElementID | null | undefined;
|
||||
selectedAXElement: ElementID | null | undefined;
|
||||
onSelect: (ids: ElementID | null | undefined) => void;
|
||||
setPersistedState: (state: Partial<PersistedState>) => void;
|
||||
persistedState: PersistedState;
|
||||
searchResults: ElementSearchResultSet | null;
|
||||
};
|
||||
|
||||
type State = {
|
||||
elementSelector: ElementSelectorData | null;
|
||||
axElementSelector: ElementSelectorData | null;
|
||||
};
|
||||
|
||||
export default class Inspector extends Component<Props, State> {
|
||||
state: State = {elementSelector: null, axElementSelector: null};
|
||||
|
||||
call() {
|
||||
return {
|
||||
GET_ROOT: this.props.ax ? 'getAXRoot' : 'getRoot',
|
||||
INVALIDATE: this.props.ax ? 'invalidateAX' : 'invalidate',
|
||||
GET_NODES: this.props.ax ? 'getAXNodes' : 'getNodes',
|
||||
SET_HIGHLIGHTED: 'setHighlighted',
|
||||
SELECT: this.props.ax ? 'selectAX' : 'select',
|
||||
INVALIDATE_WITH_DATA: this.props.ax
|
||||
? 'invalidateWithDataAX'
|
||||
: 'invalidateWithData',
|
||||
};
|
||||
}
|
||||
|
||||
selected = () => {
|
||||
return this.props.ax
|
||||
? this.props.selectedAXElement
|
||||
: this.props.selectedElement;
|
||||
};
|
||||
|
||||
root = () => {
|
||||
return this.props.ax
|
||||
? this.props.persistedState.rootAXElement
|
||||
: this.props.persistedState.rootElement;
|
||||
};
|
||||
|
||||
elements = () => {
|
||||
return this.props.ax
|
||||
? this.props.persistedState.AXelements
|
||||
: this.props.persistedState.elements;
|
||||
};
|
||||
|
||||
focused = () => {
|
||||
if (!this.props.ax) {
|
||||
return null;
|
||||
}
|
||||
const elements: Array<Element> = Object.values(
|
||||
this.props.persistedState.AXelements,
|
||||
);
|
||||
const focusedElement = elements.find((i) =>
|
||||
Boolean(
|
||||
i.data.Accessibility && i.data.Accessibility['accessibility-focused'],
|
||||
),
|
||||
);
|
||||
return focusedElement ? focusedElement.id : null;
|
||||
};
|
||||
|
||||
getAXContextMenuExtensions = () =>
|
||||
this.props.ax
|
||||
? [
|
||||
{
|
||||
label: 'Focus',
|
||||
click: (id: ElementID) => {
|
||||
if (this.props.client.isConnected) {
|
||||
this.props.client.call('onRequestAXFocus', {id});
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.client.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.props.client.call(this.call().GET_ROOT).then((root: Element) => {
|
||||
this.props.setPersistedState({
|
||||
[this.props.ax ? 'rootAXElement' : 'rootElement']: root.id,
|
||||
});
|
||||
this.updateElement(root.id, {...root, expanded: true});
|
||||
this.performInitialExpand(root);
|
||||
});
|
||||
|
||||
this.props.client.subscribe(
|
||||
this.call().INVALIDATE,
|
||||
({
|
||||
nodes,
|
||||
}: {
|
||||
nodes: Array<{id: ElementID; children: Array<ElementID>}>;
|
||||
}) => {
|
||||
const ids = nodes
|
||||
.map((n) => [n.id, ...(n.children || [])])
|
||||
.reduce((acc, cv) => acc.concat(cv), []);
|
||||
this.invalidate(ids);
|
||||
},
|
||||
);
|
||||
|
||||
this.props.client.subscribe(
|
||||
this.call().INVALIDATE_WITH_DATA,
|
||||
(obj: {nodes: Array<Element>}) => {
|
||||
const {nodes} = obj;
|
||||
this.invalidateWithData(nodes);
|
||||
},
|
||||
);
|
||||
|
||||
this.props.client.subscribe(
|
||||
this.call().SELECT,
|
||||
async ({
|
||||
path,
|
||||
tree,
|
||||
}: {
|
||||
path?: Array<ElementID>;
|
||||
tree?: ElementSelectorNode;
|
||||
}) => {
|
||||
if (path) {
|
||||
this.getAndExpandPath(path);
|
||||
}
|
||||
if (tree) {
|
||||
const leaves = this.getElementLeaves(tree);
|
||||
const elementArray = await this.getNodes(leaves, {});
|
||||
const elements = leaves.reduce(
|
||||
(acc, cur, idx) => ({...acc, [cur]: elementArray[idx]}),
|
||||
{},
|
||||
);
|
||||
if (this.props.ax) {
|
||||
this.setState({axElementSelector: {tree, leaves, elements}});
|
||||
} else {
|
||||
this.setState({elementSelector: {tree, leaves, elements}});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (this.props.ax) {
|
||||
this.props.client.subscribe('axFocusEvent', () => {
|
||||
// update all nodes, to find new focused node
|
||||
this.getNodes(Object.keys(this.props.persistedState.AXelements), {
|
||||
force: true,
|
||||
ax: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
const {ax, selectedElement, selectedAXElement} = this.props;
|
||||
|
||||
if (
|
||||
ax &&
|
||||
selectedElement &&
|
||||
selectedElement !== prevProps.selectedElement
|
||||
) {
|
||||
// selected element in non-AX tree changed, find linked element in AX tree
|
||||
const newlySelectedElem = this.props.persistedState.elements[
|
||||
selectedElement
|
||||
];
|
||||
if (newlySelectedElem) {
|
||||
this.props.onSelect(
|
||||
newlySelectedElem.extraInfo
|
||||
? newlySelectedElem.extraInfo.linkedNode
|
||||
: null,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
!ax &&
|
||||
selectedAXElement &&
|
||||
selectedAXElement !== prevProps.selectedAXElement
|
||||
) {
|
||||
// selected element in AX tree changed, find linked element in non-AX tree
|
||||
const newlySelectedAXElem = this.props.persistedState.AXelements[
|
||||
selectedAXElement
|
||||
];
|
||||
if (newlySelectedAXElem) {
|
||||
this.props.onSelect(
|
||||
newlySelectedAXElem.extraInfo
|
||||
? newlySelectedAXElem.extraInfo.linkedNode
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidateWithData(elements: Array<Element>): void {
|
||||
if (elements.length === 0) {
|
||||
return;
|
||||
}
|
||||
const updatedElements: ElementMap = elements.reduce(
|
||||
(acc: ElementMap, element: Element) => {
|
||||
acc[element.id] = {
|
||||
...element,
|
||||
expanded: this.elements()[element.id]
|
||||
? this.elements()[element.id].expanded
|
||||
: false,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
this.props.setPersistedState({
|
||||
[this.props.ax ? 'AXelements' : 'elements']: {
|
||||
...this.elements(),
|
||||
...updatedElements,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async invalidate(ids: Array<ElementID>): Promise<Array<Element>> {
|
||||
if (ids.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const elements = await this.getNodes(ids, {});
|
||||
const children = elements
|
||||
.filter(
|
||||
(element: Element) =>
|
||||
this.elements()[element.id] && this.elements()[element.id].expanded,
|
||||
)
|
||||
.map((element: Element) => element.children)
|
||||
.reduce((acc, val) => acc.concat(val), []);
|
||||
return this.invalidate(children);
|
||||
}
|
||||
|
||||
updateElement(id: ElementID, data: Object) {
|
||||
this.props.setPersistedState({
|
||||
[this.props.ax ? 'AXelements' : 'elements']: {
|
||||
...this.elements(),
|
||||
[id]: {
|
||||
...this.elements()[id],
|
||||
...data,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// When opening the inspector for the first time, expand all elements that
|
||||
// contain only 1 child recursively.
|
||||
async performInitialExpand(element: Element): Promise<void> {
|
||||
if (!element.children.length) {
|
||||
// element has no children so we're as deep as we can be
|
||||
return;
|
||||
}
|
||||
return this.getChildren(element.id, {}).then(() => {
|
||||
if (element.children.length >= 2) {
|
||||
// element has two or more children so we can stop expanding
|
||||
return;
|
||||
}
|
||||
return this.performInitialExpand(this.elements()[element.children[0]]);
|
||||
});
|
||||
}
|
||||
|
||||
async getChildren(
|
||||
id: ElementID,
|
||||
options: GetNodesOptions,
|
||||
): Promise<Array<Element>> {
|
||||
if (!this.elements()[id]) {
|
||||
await this.getNodes([id], options);
|
||||
}
|
||||
this.updateElement(id, {expanded: true});
|
||||
return this.getNodes(this.elements()[id].children, options);
|
||||
}
|
||||
|
||||
async getNodes(
|
||||
ids: Array<ElementID> = [],
|
||||
options: GetNodesOptions,
|
||||
): Promise<Array<Element>> {
|
||||
if (ids.length > 0 && this.props.client.isConnected) {
|
||||
const {forAccessibilityEvent} = options;
|
||||
const {
|
||||
elements,
|
||||
}: {elements: Array<Element>} = await this.props.client.call(
|
||||
this.call().GET_NODES,
|
||||
{
|
||||
ids,
|
||||
forAccessibilityEvent,
|
||||
selected: false,
|
||||
},
|
||||
);
|
||||
elements.forEach((e) => this.updateElement(e.id, e));
|
||||
return elements;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getAndExpandPath(path: Array<ElementID>) {
|
||||
await Promise.all(path.map((id) => this.getChildren(id, {})));
|
||||
for (const id of path) {
|
||||
this.updateElement(id, {expanded: true});
|
||||
}
|
||||
this.onElementSelected()(path[path.length - 1]);
|
||||
}
|
||||
|
||||
getElementLeaves(tree: ElementSelectorNode): Array<ElementID> {
|
||||
return tree
|
||||
? Object.entries(tree).reduce(
|
||||
(
|
||||
currLeafNode: Array<ElementID>,
|
||||
[id, children]: [ElementID, ElementSelectorNode],
|
||||
): Array<ElementID> =>
|
||||
currLeafNode.concat(
|
||||
Object.keys(children).length > 0
|
||||
? this.getElementLeaves(children)
|
||||
: [id],
|
||||
),
|
||||
[],
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
/// Return path from given tree structure and id if id is not null; otherwise return any path
|
||||
getPathForNode(
|
||||
tree: ElementSelectorNode,
|
||||
nodeID: ElementID | null,
|
||||
): Array<ElementID> | null {
|
||||
for (const node in tree) {
|
||||
if (
|
||||
node === nodeID ||
|
||||
(nodeID === null && Object.keys(tree[node]).length == 0)
|
||||
) {
|
||||
return [node];
|
||||
}
|
||||
const path = this.getPathForNode(tree[node], nodeID);
|
||||
if (path !== null) {
|
||||
return [node].concat(path);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// NOTE: this will be used in the future when we remove path and use tree instead
|
||||
async _getAndExpandPathFromTree(tree: ElementSelectorNode) {
|
||||
this.getAndExpandPath(this.getPathForNode(tree, null) ?? []);
|
||||
}
|
||||
|
||||
onElementSelected = (option?: {
|
||||
cancelSelector?: boolean;
|
||||
expandPathToElement?: boolean;
|
||||
}) =>
|
||||
debounce(async (selectedKey: ElementID) => {
|
||||
if (option?.cancelSelector) {
|
||||
this.setState({elementSelector: null, axElementSelector: null});
|
||||
}
|
||||
if (option?.expandPathToElement) {
|
||||
const data = this.props.ax
|
||||
? this.state.axElementSelector
|
||||
: this.state.elementSelector;
|
||||
await this.getAndExpandPath(
|
||||
this.getPathForNode(data?.tree ?? {}, selectedKey) ?? [],
|
||||
);
|
||||
}
|
||||
this.onElementHovered(selectedKey);
|
||||
this.props.onSelect(selectedKey);
|
||||
});
|
||||
|
||||
onElementSelectedAtMainSection = this.onElementSelected({
|
||||
cancelSelector: true,
|
||||
});
|
||||
|
||||
onElementSelectedAndExpanded = this.onElementSelected({
|
||||
expandPathToElement: true,
|
||||
});
|
||||
|
||||
onElementHovered = debounce((key: ElementID | null | undefined) => {
|
||||
if (!this.props.client.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.props.client.call(this.call().SET_HIGHLIGHTED, {
|
||||
id: key,
|
||||
isAlignmentMode: this.props.inAlignmentMode,
|
||||
});
|
||||
});
|
||||
|
||||
onElementExpanded = (
|
||||
id: ElementID,
|
||||
deep: boolean,
|
||||
forceExpand: boolean = false,
|
||||
) => {
|
||||
const shouldExpand = forceExpand || !this.elements()[id].expanded;
|
||||
if (shouldExpand) {
|
||||
this.updateElement(id, {expanded: shouldExpand});
|
||||
}
|
||||
this.getChildren(id, {}).then((children) => {
|
||||
if (deep) {
|
||||
children.forEach((child) =>
|
||||
this.onElementExpanded(child.id, deep, shouldExpand),
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!shouldExpand) {
|
||||
this.updateElement(id, {expanded: shouldExpand});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const selectorData = this.props.ax
|
||||
? this.state.axElementSelector
|
||||
: this.state.elementSelector;
|
||||
|
||||
return this.root() ? (
|
||||
<ElementsInspectorContainer>
|
||||
<ElementsInspector
|
||||
onElementSelected={this.onElementSelectedAtMainSection}
|
||||
onElementHovered={this.onElementHovered}
|
||||
onElementExpanded={this.onElementExpanded}
|
||||
searchResults={this.props.searchResults}
|
||||
selected={this.selected()}
|
||||
root={this.root()}
|
||||
elements={this.elements()}
|
||||
focused={this.focused()}
|
||||
contextMenuExtensions={this.getAXContextMenuExtensions()}
|
||||
/>
|
||||
{selectorData && selectorData.leaves.length > 1 ? (
|
||||
<MultipleSelectorSection
|
||||
initialSelectedElement={this.selected()}
|
||||
elements={selectorData.elements}
|
||||
onElementSelected={this.onElementSelectedAndExpanded}
|
||||
onElementHovered={this.onElementHovered}
|
||||
/>
|
||||
) : null}
|
||||
</ElementsInspectorContainer>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
178
desktop/plugins/public/layout/InspectorSidebar.tsx
Normal file
178
desktop/plugins/public/layout/InspectorSidebar.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
ManagedDataInspector,
|
||||
Panel,
|
||||
FlexCenter,
|
||||
styled,
|
||||
colors,
|
||||
PluginClient,
|
||||
SidebarExtensions,
|
||||
Element,
|
||||
Client,
|
||||
Logger,
|
||||
} from 'flipper';
|
||||
import {Component} from 'react';
|
||||
import deepEqual from 'deep-equal';
|
||||
import React from 'react';
|
||||
import {useMemo, useEffect} from 'react';
|
||||
import {kebabCase} from 'lodash';
|
||||
|
||||
const NoData = styled(FlexCenter)({
|
||||
fontSize: 18,
|
||||
color: colors.macOSTitleBarIcon,
|
||||
});
|
||||
|
||||
type OnValueChanged = (path: Array<string>, val: any) => void;
|
||||
|
||||
type InspectorSidebarSectionProps = {
|
||||
data: any;
|
||||
id: string;
|
||||
onValueChanged: OnValueChanged | null;
|
||||
tooltips?: Object;
|
||||
};
|
||||
|
||||
class InspectorSidebarSection extends Component<InspectorSidebarSectionProps> {
|
||||
setValue = (path: Array<string>, value: any) => {
|
||||
if (this.props.onValueChanged) {
|
||||
this.props.onValueChanged([this.props.id, ...path], value);
|
||||
}
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps: InspectorSidebarSectionProps) {
|
||||
return (
|
||||
!deepEqual(nextProps, this.props) ||
|
||||
this.props.id !== nextProps.id ||
|
||||
this.props.onValueChanged !== nextProps.onValueChanged
|
||||
);
|
||||
}
|
||||
|
||||
extractValue = (val: any, _depth: number) => {
|
||||
if (val && val.__type__) {
|
||||
return {
|
||||
mutable: Boolean(val.__mutable__),
|
||||
type: val.__type__ === 'auto' ? typeof val.value : val.__type__,
|
||||
value: val.value,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
mutable: typeof val === 'object',
|
||||
type: typeof val,
|
||||
value: val,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {id} = this.props;
|
||||
return (
|
||||
<Panel heading={id} floating={false} grow={false}>
|
||||
<ManagedDataInspector
|
||||
data={this.props.data}
|
||||
setValue={this.props.onValueChanged ? this.setValue : undefined}
|
||||
extractValue={this.extractValue}
|
||||
expandRoot={true}
|
||||
collapsed={true}
|
||||
tooltips={this.props.tooltips}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
element: Element | null;
|
||||
tooltips?: Object;
|
||||
onValueChanged: OnValueChanged | null;
|
||||
client: PluginClient;
|
||||
realClient: Client;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
const Sidebar: React.FC<Props> = (props: Props) => {
|
||||
const {element} = props;
|
||||
|
||||
const [sectionDefs, sectionKeys] = useMemo(() => {
|
||||
const sectionKeys = [];
|
||||
const sectionDefs = [];
|
||||
|
||||
if (element && element.data)
|
||||
for (const key in element.data) {
|
||||
if (key === 'Extra Sections') {
|
||||
for (const extraSection in element.data[key]) {
|
||||
const section = element.data[key][extraSection];
|
||||
let data = {};
|
||||
|
||||
// data might be sent as stringified JSON, we want to parse it for a nicer persentation.
|
||||
if (typeof section === 'string') {
|
||||
try {
|
||||
data = JSON.parse(section);
|
||||
} catch (e) {
|
||||
// data was not a valid JSON, type is required to be an object
|
||||
console.error(
|
||||
`ElementsInspector unable to parse extra section: ${extraSection}`,
|
||||
);
|
||||
data = {};
|
||||
}
|
||||
} else {
|
||||
data = section;
|
||||
}
|
||||
sectionKeys.push(kebabCase(extraSection));
|
||||
sectionDefs.push({
|
||||
key: extraSection,
|
||||
id: extraSection,
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
sectionKeys.push(kebabCase(key));
|
||||
sectionDefs.push({
|
||||
key,
|
||||
id: key,
|
||||
data: element.data[key],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [sectionDefs, sectionKeys];
|
||||
}, [props.element]);
|
||||
|
||||
const sections: Array<React.ReactNode> = (
|
||||
(SidebarExtensions &&
|
||||
element?.data &&
|
||||
SidebarExtensions.map((ext) =>
|
||||
ext(props.client, props.realClient, element, props.logger),
|
||||
)) ||
|
||||
[]
|
||||
).concat(
|
||||
sectionDefs.map((def) => (
|
||||
<InspectorSidebarSection
|
||||
tooltips={props.tooltips}
|
||||
key={def.key}
|
||||
id={def.id}
|
||||
data={def.data}
|
||||
onValueChanged={props.onValueChanged}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
sectionKeys.map((key) =>
|
||||
props.logger.track('usage', `layout-sidebar-extension:${key}:loaded`),
|
||||
);
|
||||
}, [props.element?.data]);
|
||||
|
||||
if (!element || !element.data) {
|
||||
return <NoData grow>No data</NoData>;
|
||||
}
|
||||
return <>{sections}</>;
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
97
desktop/plugins/public/layout/MultipleSelectionSection.tsx
Normal file
97
desktop/plugins/public/layout/MultipleSelectionSection.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
FlexColumn,
|
||||
FlexBox,
|
||||
Element,
|
||||
ElementsConstants,
|
||||
ElementID,
|
||||
ElementsInspector,
|
||||
Glyph,
|
||||
colors,
|
||||
styled,
|
||||
} from 'flipper';
|
||||
import React, {memo, useState} from 'react';
|
||||
|
||||
const MultipleSelectorSectionContainer = styled(FlexColumn)({
|
||||
maxHeight: 3 * ElementsConstants.rowHeight + 24,
|
||||
});
|
||||
|
||||
const MultipleSelectorSectionTitle = styled(FlexBox)({
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#f6f7f9',
|
||||
padding: '2px',
|
||||
paddingLeft: '9px',
|
||||
width: '325px',
|
||||
height: '20px',
|
||||
fontWeight: 500,
|
||||
boxShadow: '2px 2px 2px #ccc',
|
||||
border: `1px solid ${colors.light20}`,
|
||||
borderTopLeftRadius: '4px',
|
||||
borderTopRightRadius: '4px',
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
const Chevron = styled(Glyph)({
|
||||
marginRight: 4,
|
||||
marginLeft: -2,
|
||||
marginBottom: 1,
|
||||
});
|
||||
|
||||
type MultipleSelectorSectionProps = {
|
||||
initialSelectedElement: ElementID | null | undefined;
|
||||
elements: {[id: string]: Element};
|
||||
onElementSelected: (key: string) => void;
|
||||
onElementHovered:
|
||||
| ((key: string | null | undefined) => any)
|
||||
| null
|
||||
| undefined;
|
||||
};
|
||||
|
||||
const MultipleSelectorSection: React.FC<MultipleSelectorSectionProps> = memo(
|
||||
(props: MultipleSelectorSectionProps) => {
|
||||
const {
|
||||
initialSelectedElement,
|
||||
elements,
|
||||
onElementSelected,
|
||||
onElementHovered,
|
||||
} = props;
|
||||
const [selectedId, setSelectedId] = useState<ElementID | null | undefined>(
|
||||
initialSelectedElement,
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
return (
|
||||
<MultipleSelectorSectionContainer>
|
||||
<MultipleSelectorSectionTitle
|
||||
onClick={() => {
|
||||
setCollapsed(!collapsed);
|
||||
}}>
|
||||
<Chevron name={collapsed ? 'chevron-up' : 'chevron-down'} size={12} />
|
||||
Multiple elements found at the target coordinates
|
||||
</MultipleSelectorSectionTitle>
|
||||
{!collapsed && (
|
||||
<ElementsInspector
|
||||
onElementSelected={(key: string) => {
|
||||
setSelectedId(key);
|
||||
onElementSelected(key);
|
||||
}}
|
||||
onElementHovered={onElementHovered}
|
||||
onElementExpanded={() => {}}
|
||||
root={null}
|
||||
selected={selectedId}
|
||||
elements={elements}
|
||||
/>
|
||||
)}
|
||||
</MultipleSelectorSectionContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default MultipleSelectorSection;
|
||||
197
desktop/plugins/public/layout/ProxyArchiveClient.tsx
Normal file
197
desktop/plugins/public/layout/ProxyArchiveClient.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Element} from 'flipper';
|
||||
import {PersistedState} from './index';
|
||||
import {SearchResultTree} from './Search';
|
||||
import {cloneDeep} from 'lodash';
|
||||
|
||||
const propsForPersistedState = (
|
||||
AXMode: boolean,
|
||||
): {
|
||||
ROOT: 'rootAXElement' | 'rootElement';
|
||||
ELEMENTS: 'AXelements' | 'elements';
|
||||
ELEMENT: 'axElement' | 'element';
|
||||
} => {
|
||||
return {
|
||||
ROOT: AXMode ? 'rootAXElement' : 'rootElement',
|
||||
ELEMENTS: AXMode ? 'AXelements' : 'elements',
|
||||
ELEMENT: AXMode ? 'axElement' : 'element',
|
||||
};
|
||||
};
|
||||
|
||||
function constructSearchResultTree(
|
||||
node: Element,
|
||||
isMatch: boolean,
|
||||
children: Array<SearchResultTree>,
|
||||
_AXMode: boolean,
|
||||
AXNode: Element | null,
|
||||
): SearchResultTree {
|
||||
const searchResult = {
|
||||
id: node.id,
|
||||
isMatch,
|
||||
hasChildren: children.length > 0,
|
||||
children: children.length > 0 ? children : [],
|
||||
element: node,
|
||||
axElement: AXNode,
|
||||
};
|
||||
return searchResult;
|
||||
}
|
||||
|
||||
function isMatch(element: Element, query: string): boolean {
|
||||
const nameMatch = element.name.toLowerCase().includes(query.toLowerCase());
|
||||
return nameMatch || element.id === query;
|
||||
}
|
||||
|
||||
export function searchNodes(
|
||||
node: Element,
|
||||
query: string,
|
||||
AXMode: boolean,
|
||||
state: PersistedState,
|
||||
): SearchResultTree | null {
|
||||
// Even if the axMode is true, we will have to search the normal elements too.
|
||||
// The AXEelements will automatically populated in constructSearchResultTree
|
||||
const elements = state[propsForPersistedState(false).ELEMENTS];
|
||||
const children: Array<SearchResultTree> = [];
|
||||
const match = isMatch(node, query);
|
||||
|
||||
for (const childID of node.children) {
|
||||
const child = elements[childID];
|
||||
const tree = searchNodes(child, query, AXMode, state);
|
||||
if (tree) {
|
||||
children.push(tree);
|
||||
}
|
||||
}
|
||||
|
||||
if (match || children.length > 0) {
|
||||
return cloneDeep(
|
||||
constructSearchResultTree(
|
||||
node,
|
||||
match,
|
||||
children,
|
||||
AXMode,
|
||||
AXMode ? state.AXelements[node.id] : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class ProxyArchiveClient {
|
||||
constructor(
|
||||
persistedState: PersistedState,
|
||||
onElementHighlighted?: (id: string) => void,
|
||||
) {
|
||||
this.persistedState = cloneDeep(persistedState);
|
||||
this.onElementHighlighted = onElementHighlighted;
|
||||
}
|
||||
|
||||
isConnected = true;
|
||||
|
||||
persistedState: PersistedState;
|
||||
onElementHighlighted: ((id: string) => void) | undefined;
|
||||
subscribe(_method: string, _callback: (params: any) => void): void {
|
||||
return;
|
||||
}
|
||||
|
||||
supportsMethod(_method: string): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
send(_method: string, _params?: Object): void {
|
||||
return;
|
||||
}
|
||||
|
||||
call(method: string, paramaters?: {[key: string]: any}): Promise<any> {
|
||||
switch (method) {
|
||||
case 'getRoot': {
|
||||
const {rootElement} = this.persistedState;
|
||||
if (!rootElement) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.resolve(this.persistedState.elements[rootElement]);
|
||||
}
|
||||
case 'getAXRoot': {
|
||||
const {rootAXElement} = this.persistedState;
|
||||
if (!rootAXElement) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.resolve(this.persistedState.AXelements[rootAXElement]);
|
||||
}
|
||||
case 'getNodes': {
|
||||
if (!paramaters) {
|
||||
return Promise.reject(new Error('Called getNodes with no params'));
|
||||
}
|
||||
const {ids} = paramaters;
|
||||
const arr: Array<Element> = [];
|
||||
for (const id of ids) {
|
||||
arr.push(this.persistedState.elements[id]);
|
||||
}
|
||||
return Promise.resolve({elements: arr});
|
||||
}
|
||||
case 'getAXNodes': {
|
||||
if (!paramaters) {
|
||||
return Promise.reject(new Error('Called getAXNodes with no params'));
|
||||
}
|
||||
const {ids} = paramaters;
|
||||
const arr: Array<Element> = [];
|
||||
for (const id of ids) {
|
||||
arr.push(this.persistedState.AXelements[id]);
|
||||
}
|
||||
return Promise.resolve({elements: arr});
|
||||
}
|
||||
case 'getSearchResults': {
|
||||
const {rootElement, rootAXElement} = this.persistedState;
|
||||
|
||||
if (!paramaters) {
|
||||
return Promise.reject(
|
||||
new Error('Called getSearchResults with no params'),
|
||||
);
|
||||
}
|
||||
const {query, axEnabled} = paramaters;
|
||||
if (!query) {
|
||||
return Promise.reject(
|
||||
new Error('query is not passed as a params to getSearchResults'),
|
||||
);
|
||||
}
|
||||
let element: Element;
|
||||
if (axEnabled) {
|
||||
if (!rootAXElement) {
|
||||
return Promise.reject(new Error('rootAXElement is undefined'));
|
||||
}
|
||||
element = this.persistedState.AXelements[rootAXElement];
|
||||
} else {
|
||||
if (!rootElement) {
|
||||
return Promise.reject(new Error('rootElement is undefined'));
|
||||
}
|
||||
element = this.persistedState.elements[rootElement];
|
||||
}
|
||||
const output = searchNodes(
|
||||
element,
|
||||
query,
|
||||
axEnabled,
|
||||
this.persistedState,
|
||||
);
|
||||
return Promise.resolve({results: output, query});
|
||||
}
|
||||
case 'isConsoleEnabled': {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
case 'setHighlighted': {
|
||||
const id = paramaters?.id;
|
||||
this.onElementHighlighted && this.onElementHighlighted(id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
default: {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export default ProxyArchiveClient;
|
||||
211
desktop/plugins/public/layout/Search.tsx
Normal file
211
desktop/plugins/public/layout/Search.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {PersistedState, ElementMap} from './';
|
||||
import {
|
||||
PluginClient,
|
||||
ElementSearchResultSet,
|
||||
Element,
|
||||
SearchInput,
|
||||
SearchBox,
|
||||
SearchIcon,
|
||||
LoadingIndicator,
|
||||
styled,
|
||||
colors,
|
||||
} from 'flipper';
|
||||
import {Component} from 'react';
|
||||
import React from 'react';
|
||||
|
||||
export type SearchResultTree = {
|
||||
id: string;
|
||||
isMatch: boolean;
|
||||
hasChildren: boolean;
|
||||
children: Array<SearchResultTree>;
|
||||
element: Element;
|
||||
axElement: Element | null; // Not supported in iOS
|
||||
};
|
||||
|
||||
type Props = {
|
||||
client: PluginClient;
|
||||
inAXMode: boolean;
|
||||
onSearchResults: (searchResults: ElementSearchResultSet) => void;
|
||||
setPersistedState: (state: Partial<PersistedState>) => void;
|
||||
persistedState: PersistedState;
|
||||
initialQuery: string | null;
|
||||
};
|
||||
|
||||
type State = {
|
||||
value: string;
|
||||
outstandingSearchQuery: string | null;
|
||||
};
|
||||
|
||||
const LoadingSpinner = styled(LoadingIndicator)({
|
||||
marginRight: 4,
|
||||
marginLeft: 3,
|
||||
marginTop: -1,
|
||||
});
|
||||
|
||||
export default class Search extends Component<Props, State> {
|
||||
state = {
|
||||
value: '',
|
||||
outstandingSearchQuery: null,
|
||||
};
|
||||
|
||||
timer: NodeJS.Timeout | undefined;
|
||||
|
||||
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
const {value} = e.target;
|
||||
this.setState({value});
|
||||
this.timer = setTimeout(() => this.performSearch(value), 200);
|
||||
};
|
||||
|
||||
onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.performSearch(this.state.value);
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.initialQuery) {
|
||||
const queryString = this.props.initialQuery
|
||||
? this.props.initialQuery
|
||||
: '';
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.timer = setTimeout(() => this.performSearch(queryString), 200);
|
||||
}
|
||||
}
|
||||
|
||||
performSearch(query: string) {
|
||||
this.setState({
|
||||
outstandingSearchQuery: query,
|
||||
});
|
||||
|
||||
if (!query) {
|
||||
this.displaySearchResults(
|
||||
{query: '', results: null},
|
||||
this.props.inAXMode,
|
||||
);
|
||||
} else {
|
||||
this.props.client
|
||||
.call('getSearchResults', {query, axEnabled: this.props.inAXMode})
|
||||
.then((response) =>
|
||||
this.displaySearchResults(response, this.props.inAXMode),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
displaySearchResults(
|
||||
{
|
||||
results,
|
||||
query,
|
||||
}: {
|
||||
results: SearchResultTree | null;
|
||||
query: string;
|
||||
},
|
||||
axMode: boolean,
|
||||
) {
|
||||
this.setState({
|
||||
outstandingSearchQuery:
|
||||
query === this.state.outstandingSearchQuery
|
||||
? null
|
||||
: this.state.outstandingSearchQuery,
|
||||
});
|
||||
|
||||
const searchResults = this.getElementsFromSearchResultTree(results);
|
||||
const searchResultIDs = new Set(searchResults.map((r) => r.element.id));
|
||||
const elements: ElementMap = searchResults.reduce(
|
||||
(acc: ElementMap, {element}: SearchResultTree) => ({
|
||||
...acc,
|
||||
[element.id]: {
|
||||
...element,
|
||||
// expand all search results, that we have have children for
|
||||
expanded: element.children.some((c) => searchResultIDs.has(c)),
|
||||
},
|
||||
}),
|
||||
this.props.persistedState.elements,
|
||||
);
|
||||
|
||||
let {AXelements} = this.props.persistedState;
|
||||
if (axMode) {
|
||||
AXelements = searchResults.reduce(
|
||||
(acc: ElementMap, {axElement}: SearchResultTree) => {
|
||||
if (!axElement) {
|
||||
return acc;
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
[axElement.id]: {
|
||||
...axElement,
|
||||
// expand all search results, that we have have children for
|
||||
expanded: axElement.children.some((c) => searchResultIDs.has(c)),
|
||||
},
|
||||
};
|
||||
},
|
||||
this.props.persistedState.AXelements,
|
||||
);
|
||||
}
|
||||
|
||||
this.props.setPersistedState({elements, AXelements});
|
||||
|
||||
this.props.onSearchResults({
|
||||
matches: new Set(
|
||||
searchResults.filter((x) => x.isMatch).map((x) => x.element.id),
|
||||
),
|
||||
query: query,
|
||||
});
|
||||
}
|
||||
|
||||
getElementsFromSearchResultTree(
|
||||
tree: SearchResultTree | null,
|
||||
): Array<SearchResultTree> {
|
||||
if (!tree) {
|
||||
return [];
|
||||
}
|
||||
let elements = [
|
||||
{
|
||||
children: [] as Array<SearchResultTree>,
|
||||
id: tree.id,
|
||||
isMatch: tree.isMatch,
|
||||
hasChildren: Boolean(tree.children),
|
||||
element: tree.element,
|
||||
axElement: tree.axElement,
|
||||
},
|
||||
];
|
||||
if (tree.children) {
|
||||
for (const child of tree.children) {
|
||||
elements = elements.concat(this.getElementsFromSearchResultTree(child));
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SearchBox tabIndex={-1}>
|
||||
<SearchIcon
|
||||
name="magnifying-glass"
|
||||
color={colors.macOSTitleBarIcon}
|
||||
size={16}
|
||||
/>
|
||||
<SearchInput
|
||||
placeholder={'Search'}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
value={this.state.value}
|
||||
/>
|
||||
{this.state.outstandingSearchQuery && <LoadingSpinner size={16} />}
|
||||
</SearchBox>
|
||||
);
|
||||
}
|
||||
}
|
||||
121
desktop/plugins/public/layout/__tests__/Inspector.node.tsx
Normal file
121
desktop/plugins/public/layout/__tests__/Inspector.node.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import Inspector, {ElementSelectorNode} from '../Inspector';
|
||||
import {PluginClient, Element} from 'flipper';
|
||||
import React from 'react';
|
||||
import {render} from '@testing-library/react';
|
||||
|
||||
let inspectorComponent: Inspector | null = null;
|
||||
beforeEach(() => {
|
||||
const mockRoot: Element = {
|
||||
id: '10000',
|
||||
name: '10000',
|
||||
expanded: false,
|
||||
children: [],
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: '',
|
||||
extraInfo: {},
|
||||
};
|
||||
const client: PluginClient = {
|
||||
isConnected: true,
|
||||
send: () => {},
|
||||
call: () => Promise.resolve(mockRoot),
|
||||
subscribe: () => {},
|
||||
supportsMethod: () => Promise.resolve(false),
|
||||
};
|
||||
render(
|
||||
<Inspector
|
||||
client={client}
|
||||
showsSidebar={false}
|
||||
selectedElement={null}
|
||||
selectedAXElement={null}
|
||||
onSelect={() => {}}
|
||||
setPersistedState={() => {}}
|
||||
persistedState={{
|
||||
rootElement: null,
|
||||
rootAXElement: null,
|
||||
elements: {},
|
||||
AXelements: {},
|
||||
}}
|
||||
searchResults={null}
|
||||
ref={(e) => {
|
||||
inspectorComponent = e;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
function constructTestTree(): ElementSelectorNode {
|
||||
// The tree will be:
|
||||
// 10000 ---> 11000 ---> 11100 ---> 11110
|
||||
// | | +-> 11120
|
||||
// | +-> 11200
|
||||
// +--> 12000 ---> 12100
|
||||
// +-> 12200 ---> 12210 ---> 12211
|
||||
// +-> 12300 ---> 12310
|
||||
// +-> 12320
|
||||
return {
|
||||
10000: {
|
||||
11000: {11100: {11110: {}, 11120: {}}, 11200: {}},
|
||||
12000: {
|
||||
12100: {},
|
||||
12200: {12210: {12211: {}}},
|
||||
12300: {12310: {}, 12320: {}},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('test getPathFromNode without id', () => {
|
||||
const tree = constructTestTree();
|
||||
const path = inspectorComponent?.getPathForNode(tree, null);
|
||||
let subtree = tree;
|
||||
path?.forEach((id) => {
|
||||
subtree = subtree[id];
|
||||
expect(subtree).toBeDefined();
|
||||
});
|
||||
expect(subtree).toEqual({});
|
||||
});
|
||||
|
||||
test('test getPathFromNode with id (leaf)', () => {
|
||||
const tree = constructTestTree();
|
||||
const path = inspectorComponent?.getPathForNode(tree, '12320');
|
||||
expect(path).toEqual(['10000', '12000', '12300', '12320']);
|
||||
});
|
||||
|
||||
test('test getPathFromNode with id (non-leaf)', () => {
|
||||
const tree = constructTestTree();
|
||||
const path = inspectorComponent?.getPathForNode(tree, '12210');
|
||||
expect(path).toEqual(['10000', '12000', '12200', '12210']);
|
||||
});
|
||||
|
||||
test('test getPathFromNode with non-existing id', () => {
|
||||
const tree = constructTestTree();
|
||||
const path = inspectorComponent?.getPathForNode(tree, '12313');
|
||||
expect(path).toBeNull();
|
||||
});
|
||||
|
||||
test('test getElementLeaves', () => {
|
||||
const tree = constructTestTree();
|
||||
const leaves = inspectorComponent?.getElementLeaves(tree);
|
||||
expect(leaves).toHaveLength(7);
|
||||
expect(leaves).toEqual(
|
||||
expect.arrayContaining([
|
||||
'11110',
|
||||
'11120',
|
||||
'11200',
|
||||
'12100',
|
||||
'12211',
|
||||
'12310',
|
||||
'12320',
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {render, fireEvent} from '@testing-library/react';
|
||||
|
||||
import {Element} from 'flipper';
|
||||
import MultipleSelectorSection from '../MultipleSelectionSection';
|
||||
|
||||
const TITLE_STRING = 'Multiple elements found at the target coordinates';
|
||||
const dummyElmentData: Omit<Element, 'id' | 'name'> = {
|
||||
expanded: false,
|
||||
children: [],
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: '',
|
||||
extraInfo: {},
|
||||
};
|
||||
|
||||
test('rendering a single element', () => {
|
||||
const id = 'id1';
|
||||
const name = 'id_name';
|
||||
const element: Element = {...dummyElmentData, id, name};
|
||||
const res = render(
|
||||
<MultipleSelectorSection
|
||||
initialSelectedElement={null}
|
||||
elements={{[id]: element}}
|
||||
onElementSelected={() => {}}
|
||||
onElementHovered={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(res.queryByText(TITLE_STRING)).toBeDefined();
|
||||
expect(res.queryAllByText(name).length).toBe(1);
|
||||
});
|
||||
|
||||
test('collapsing an element', () => {
|
||||
const id = 'id1';
|
||||
const name = 'id_name';
|
||||
const element: Element = {...dummyElmentData, id, name};
|
||||
const res = render(
|
||||
<MultipleSelectorSection
|
||||
initialSelectedElement={null}
|
||||
elements={{[id]: element}}
|
||||
onElementSelected={() => {}}
|
||||
onElementHovered={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(res.queryAllByText(name).length).toBe(1);
|
||||
|
||||
// collapse the view
|
||||
fireEvent.click(res.getByText(TITLE_STRING));
|
||||
expect(res.queryAllByText(name).length).toBe(0);
|
||||
|
||||
// re-expand the view
|
||||
fireEvent.click(res.getByText(TITLE_STRING));
|
||||
expect(res.queryAllByText(name).length).toBe(1);
|
||||
});
|
||||
|
||||
test('clicking on elements', () => {
|
||||
const ids = ['id1', 'id2', 'id3'];
|
||||
const names = ['id_name_first', 'id_name_second', 'id_name_third'];
|
||||
const elements: {[id: string]: Element} = ids.reduce(
|
||||
(acc: {[id: string]: Element}, id, idx) => {
|
||||
acc[id] = {...dummyElmentData, id, name: names[idx]};
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const mockOnElementSelected = jest.fn((_key: string) => {});
|
||||
window.scrollTo = () => {};
|
||||
const res = render(
|
||||
<MultipleSelectorSection
|
||||
initialSelectedElement={null}
|
||||
elements={elements}
|
||||
onElementSelected={mockOnElementSelected}
|
||||
onElementHovered={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickingIdx = [0, 1, 2, 1, 0];
|
||||
clickingIdx.forEach((idx) => fireEvent.click(res.getByText(names[idx])));
|
||||
|
||||
// expect all click to call the function
|
||||
expect(mockOnElementSelected.mock.calls.length).toBe(clickingIdx.length);
|
||||
clickingIdx.forEach((valIdx, idx) =>
|
||||
expect(mockOnElementSelected.mock.calls[idx][0]).toBe(ids[valIdx]),
|
||||
);
|
||||
});
|
||||
|
||||
test('hovering on elements', () => {
|
||||
const ids = ['id1', 'id2', 'id3'];
|
||||
const names = ['id_name_first', 'id_name_second', 'id_name_third'];
|
||||
const elements: {[id: string]: Element} = ids.reduce(
|
||||
(acc: {[id: string]: Element}, id, idx) => {
|
||||
acc[id] = {...dummyElmentData, id, name: names[idx]};
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const mockOnElementSelected = jest.fn((_key: string) => {});
|
||||
const mockOnElementHovered = jest.fn((_key: string | null | undefined) => {});
|
||||
window.scrollTo = () => {};
|
||||
const res = render(
|
||||
<MultipleSelectorSection
|
||||
initialSelectedElement={null}
|
||||
elements={elements}
|
||||
onElementSelected={mockOnElementSelected}
|
||||
onElementHovered={mockOnElementHovered}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickingIdx = [0, 1, 2, 1, 0];
|
||||
clickingIdx.forEach((idx) => fireEvent.mouseOver(res.getByText(names[idx])));
|
||||
|
||||
// expect all hover to call the function
|
||||
expect(mockOnElementHovered.mock.calls.length).toBe(clickingIdx.length);
|
||||
clickingIdx.forEach((valIdx, idx) =>
|
||||
expect(mockOnElementHovered.mock.calls[idx][0]).toBe(ids[valIdx]),
|
||||
);
|
||||
|
||||
// expect no click to be called
|
||||
expect(mockOnElementSelected.mock.calls.length).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
default as ProxyArchiveClient,
|
||||
searchNodes,
|
||||
} from '../ProxyArchiveClient';
|
||||
import {PersistedState, ElementMap} from '../index';
|
||||
import {ElementID, Element} from 'flipper';
|
||||
import {SearchResultTree} from '../Search';
|
||||
|
||||
function constructElement(
|
||||
id: string,
|
||||
name: string,
|
||||
children: Array<ElementID>,
|
||||
): Element {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
expanded: false,
|
||||
children,
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: 'decoration',
|
||||
extraInfo: {},
|
||||
};
|
||||
}
|
||||
|
||||
function constructPersistedState(axMode: boolean): PersistedState {
|
||||
if (!axMode) {
|
||||
return {
|
||||
rootElement: 'root',
|
||||
rootAXElement: null,
|
||||
elements: {},
|
||||
AXelements: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
rootElement: null,
|
||||
rootAXElement: 'root',
|
||||
elements: {},
|
||||
AXelements: {},
|
||||
};
|
||||
}
|
||||
let state = constructPersistedState(false);
|
||||
|
||||
function populateChildren(state: PersistedState, axMode: boolean) {
|
||||
const elements: ElementMap = {};
|
||||
elements['root'] = constructElement('root', 'root view', [
|
||||
'child0',
|
||||
'child1',
|
||||
]);
|
||||
|
||||
elements['child0'] = constructElement('child0', 'child0 view', [
|
||||
'child0_child0',
|
||||
'child0_child1',
|
||||
]);
|
||||
elements['child1'] = constructElement('child1', 'child1 view', [
|
||||
'child1_child0',
|
||||
'child1_child1',
|
||||
]);
|
||||
elements['child0_child0'] = constructElement(
|
||||
'child0_child0',
|
||||
'child0_child0 view',
|
||||
[],
|
||||
);
|
||||
elements['child0_child1'] = constructElement(
|
||||
'child0_child1',
|
||||
'child0_child1 view',
|
||||
[],
|
||||
);
|
||||
elements['child1_child0'] = constructElement(
|
||||
'child1_child0',
|
||||
'child1_child0 view',
|
||||
[],
|
||||
);
|
||||
elements['child1_child1'] = constructElement(
|
||||
'child1_child1',
|
||||
'child1_child1 view',
|
||||
[],
|
||||
);
|
||||
state.elements = elements;
|
||||
if (axMode) {
|
||||
state.AXelements = elements;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state = constructPersistedState(false);
|
||||
populateChildren(state, false);
|
||||
});
|
||||
|
||||
test('test the searchNode for root in axMode false', async () => {
|
||||
const searchResult: SearchResultTree | null = await searchNodes(
|
||||
state.elements['root'],
|
||||
'root',
|
||||
false,
|
||||
state,
|
||||
);
|
||||
expect(searchResult).toBeDefined();
|
||||
expect(searchResult).toEqual({
|
||||
id: 'root',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['root'],
|
||||
axElement: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('test the searchNode for root in axMode true', async () => {
|
||||
state = constructPersistedState(true);
|
||||
populateChildren(state, true);
|
||||
const searchResult: SearchResultTree | null = await searchNodes(
|
||||
state.AXelements['root'],
|
||||
'RoOT',
|
||||
true,
|
||||
state,
|
||||
);
|
||||
expect(searchResult).toBeDefined();
|
||||
expect(searchResult).toEqual({
|
||||
id: 'root',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.AXelements['root'], // Even though AXElement exists, normal element will exist too
|
||||
axElement: state.AXelements['root'],
|
||||
});
|
||||
});
|
||||
|
||||
test('test the searchNode which matches just one child', async () => {
|
||||
const searchResult: SearchResultTree | null = await searchNodes(
|
||||
state.elements['root'],
|
||||
'child0_child0',
|
||||
false,
|
||||
state,
|
||||
);
|
||||
expect(searchResult).toBeDefined();
|
||||
expect(searchResult).toEqual({
|
||||
id: 'root',
|
||||
isMatch: false,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child0',
|
||||
isMatch: false,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child0_child0',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child0_child0'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['child0'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['root'],
|
||||
axElement: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('test the searchNode for which matches multiple child', async () => {
|
||||
const searchResult: SearchResultTree | null = await searchNodes(
|
||||
state.elements['root'],
|
||||
'child0',
|
||||
false,
|
||||
state,
|
||||
);
|
||||
expect(searchResult).toBeDefined();
|
||||
const expectedSearchResult = {
|
||||
id: 'root',
|
||||
isMatch: false,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child0',
|
||||
isMatch: true,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child0_child0',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child0_child0'],
|
||||
axElement: null,
|
||||
},
|
||||
{
|
||||
id: 'child0_child1',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child0_child1'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['child0'],
|
||||
axElement: null,
|
||||
},
|
||||
{
|
||||
id: 'child1',
|
||||
isMatch: false,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child1_child0',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child1_child0'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['child1'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['root'],
|
||||
axElement: null,
|
||||
};
|
||||
expect(searchResult).toEqual(expectedSearchResult);
|
||||
});
|
||||
|
||||
test('test the searchNode, it should not be case sensitive', async () => {
|
||||
const searchResult: SearchResultTree | null = await searchNodes(
|
||||
state.elements['root'],
|
||||
'ChIlD0',
|
||||
false,
|
||||
state,
|
||||
);
|
||||
expect(searchResult).toBeDefined();
|
||||
const expectedSearchResult = {
|
||||
id: 'root',
|
||||
isMatch: false,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child0',
|
||||
isMatch: true,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child0_child0',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child0_child0'],
|
||||
axElement: null,
|
||||
},
|
||||
{
|
||||
id: 'child0_child1',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child0_child1'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['child0'],
|
||||
axElement: null,
|
||||
},
|
||||
{
|
||||
id: 'child1',
|
||||
isMatch: false,
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: 'child1_child0',
|
||||
isMatch: true,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
element: state.elements['child1_child0'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['child1'],
|
||||
axElement: null,
|
||||
},
|
||||
],
|
||||
element: state.elements['root'],
|
||||
axElement: null,
|
||||
};
|
||||
expect(searchResult).toEqual(expectedSearchResult);
|
||||
});
|
||||
|
||||
test('test the searchNode for non existent query', async () => {
|
||||
const searchResult: SearchResultTree | null = await searchNodes(
|
||||
state.elements['root'],
|
||||
'Unknown query',
|
||||
false,
|
||||
state,
|
||||
);
|
||||
expect(searchResult).toBeNull();
|
||||
});
|
||||
|
||||
test('test the call method with getRoot', async () => {
|
||||
const proxyClient = new ProxyArchiveClient(state);
|
||||
const root: Element = await proxyClient.call('getRoot');
|
||||
expect(root).toEqual(state.elements['root']);
|
||||
});
|
||||
|
||||
test('test the call method with getAXRoot', async () => {
|
||||
state = constructPersistedState(true);
|
||||
populateChildren(state, true);
|
||||
const proxyClient = new ProxyArchiveClient(state);
|
||||
const root: Element = await proxyClient.call('getAXRoot');
|
||||
expect(root).toEqual(state.AXelements['root']);
|
||||
});
|
||||
|
||||
test('test the call method with getNodes', async () => {
|
||||
const proxyClient = new ProxyArchiveClient(state);
|
||||
const nodes: Array<Element> = await proxyClient.call('getNodes', {
|
||||
ids: ['child0_child1', 'child1_child0'],
|
||||
});
|
||||
expect(nodes).toEqual({
|
||||
elements: [
|
||||
{
|
||||
id: 'child0_child1',
|
||||
name: 'child0_child1 view',
|
||||
expanded: false,
|
||||
children: [],
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: 'decoration',
|
||||
extraInfo: {},
|
||||
},
|
||||
{
|
||||
id: 'child1_child0',
|
||||
name: 'child1_child0 view',
|
||||
expanded: false,
|
||||
children: [],
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: 'decoration',
|
||||
extraInfo: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('test the call method with getAXNodes', async () => {
|
||||
state = constructPersistedState(true);
|
||||
populateChildren(state, true);
|
||||
const proxyClient = new ProxyArchiveClient(state);
|
||||
const nodes: Array<Element> = await proxyClient.call('getAXNodes', {
|
||||
ids: ['child0_child1', 'child1_child0'],
|
||||
});
|
||||
expect(nodes).toEqual({
|
||||
elements: [
|
||||
{
|
||||
id: 'child0_child1',
|
||||
name: 'child0_child1 view',
|
||||
expanded: false,
|
||||
children: [],
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: 'decoration',
|
||||
extraInfo: {},
|
||||
},
|
||||
{
|
||||
id: 'child1_child0',
|
||||
name: 'child1_child0 view',
|
||||
expanded: false,
|
||||
children: [],
|
||||
attributes: [],
|
||||
data: {},
|
||||
decoration: 'decoration',
|
||||
extraInfo: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('test different methods of calls with no params', async () => {
|
||||
const proxyClient = new ProxyArchiveClient(state);
|
||||
await expect(proxyClient.call('getNodes')).rejects.toThrow(
|
||||
new Error('Called getNodes with no params'),
|
||||
);
|
||||
await expect(proxyClient.call('getAXNodes')).rejects.toThrow(
|
||||
new Error('Called getAXNodes with no params'),
|
||||
);
|
||||
// let result: Error = await proxyClient.call('getSearchResults');
|
||||
await expect(proxyClient.call('getSearchResults')).rejects.toThrow(
|
||||
new Error('Called getSearchResults with no params'),
|
||||
);
|
||||
await expect(
|
||||
proxyClient.call('getSearchResults', {
|
||||
query: 'random',
|
||||
axEnabled: true,
|
||||
}),
|
||||
).rejects.toThrow(new Error('rootAXElement is undefined'));
|
||||
await expect(
|
||||
proxyClient.call('getSearchResults', {
|
||||
axEnabled: false,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
new Error('query is not passed as a params to getSearchResults'),
|
||||
);
|
||||
});
|
||||
|
||||
test('test call method isConsoleEnabled', () => {
|
||||
const proxyClient = new ProxyArchiveClient(state);
|
||||
return expect(proxyClient.call('isConsoleEnabled')).resolves.toBe(false);
|
||||
});
|
||||
560
desktop/plugins/public/layout/index.tsx
Normal file
560
desktop/plugins/public/layout/index.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
ElementID,
|
||||
Element,
|
||||
ElementSearchResultSet,
|
||||
PluginClient,
|
||||
FlipperPlugin,
|
||||
Toolbar,
|
||||
DetailSidebar,
|
||||
Button,
|
||||
GK,
|
||||
Idler,
|
||||
ReduxState,
|
||||
ArchivedDevice,
|
||||
ToolbarIcon,
|
||||
Layout,
|
||||
Sidebar,
|
||||
} from 'flipper';
|
||||
import Inspector from './Inspector';
|
||||
import InspectorSidebar from './InspectorSidebar';
|
||||
import Search from './Search';
|
||||
import ProxyArchiveClient from './ProxyArchiveClient';
|
||||
import React from 'react';
|
||||
import {
|
||||
VisualizerPortal,
|
||||
getFlipperMediaCDN,
|
||||
IDEFileResolver,
|
||||
IDEType,
|
||||
} from 'flipper';
|
||||
|
||||
type State = {
|
||||
init: boolean;
|
||||
inTargetMode: boolean;
|
||||
inAXMode: boolean;
|
||||
inAlignmentMode: boolean;
|
||||
selectedElement: ElementID | null | undefined;
|
||||
selectedAXElement: ElementID | null | undefined;
|
||||
highlightedElement: ElementID | null;
|
||||
searchResults: ElementSearchResultSet | null;
|
||||
visualizerWindow: Window | null;
|
||||
visualizerScreenshot: string | null;
|
||||
screenDimensions: {width: number; height: number} | null;
|
||||
};
|
||||
|
||||
export type ElementMap = {[key: string]: Element};
|
||||
|
||||
export type PersistedState = {
|
||||
rootElement: ElementID | null;
|
||||
rootAXElement: ElementID | null;
|
||||
elements: ElementMap;
|
||||
AXelements: ElementMap;
|
||||
};
|
||||
type ClientGetNodesCalls = 'getNodes' | 'getAXNodes';
|
||||
type ClientMethodCalls = 'getRoot' | 'getAXRoot' | ClientGetNodesCalls;
|
||||
|
||||
type ClassFileParams = {
|
||||
fileName: string;
|
||||
className: string;
|
||||
dirRoot: string;
|
||||
};
|
||||
|
||||
type OpenFileParams = {
|
||||
resolvedPath: string;
|
||||
ide: IDEType;
|
||||
repo: string;
|
||||
lineNumber: number;
|
||||
};
|
||||
|
||||
export default class LayoutPlugin extends FlipperPlugin<
|
||||
State,
|
||||
any,
|
||||
PersistedState
|
||||
> {
|
||||
static exportPersistedState = async (
|
||||
callClient:
|
||||
| undefined
|
||||
| ((method: ClientMethodCalls, params?: any) => Promise<any>),
|
||||
persistedState: PersistedState | undefined,
|
||||
store: ReduxState | undefined,
|
||||
_idler?: Idler | undefined,
|
||||
statusUpdate?: (msg: string) => void,
|
||||
supportsMethod?: (method: ClientMethodCalls) => Promise<boolean>,
|
||||
): Promise<PersistedState | undefined> => {
|
||||
if (!store || !callClient) {
|
||||
return persistedState;
|
||||
}
|
||||
statusUpdate && statusUpdate('Fetching Root Node...');
|
||||
// We need not check the if the client supports `getRoot` as if it should and if it doesn't we will get a suppressed notification in Flipper and things will still export, but we will get an error surfaced.
|
||||
const rootElement: Element | null = await callClient('getRoot');
|
||||
const rootAXElement: Element | null =
|
||||
supportsMethod && (await supportsMethod('getAXRoot')) // getAXRoot only relevant for Android
|
||||
? await callClient('getAXRoot')
|
||||
: null;
|
||||
const elements: ElementMap = {};
|
||||
|
||||
if (rootElement) {
|
||||
statusUpdate && statusUpdate('Fetching Child Nodes...');
|
||||
await LayoutPlugin.getAllNodes(
|
||||
rootElement,
|
||||
elements,
|
||||
callClient,
|
||||
'getNodes',
|
||||
supportsMethod,
|
||||
);
|
||||
}
|
||||
const AXelements: ElementMap = {};
|
||||
if (rootAXElement) {
|
||||
statusUpdate && statusUpdate('Fetching Child AX Nodes...');
|
||||
await LayoutPlugin.getAllNodes(
|
||||
rootAXElement,
|
||||
AXelements,
|
||||
callClient,
|
||||
'getAXNodes',
|
||||
supportsMethod,
|
||||
);
|
||||
}
|
||||
statusUpdate && statusUpdate('Finished Fetching Child Nodes...');
|
||||
return {
|
||||
rootElement: rootElement != undefined ? rootElement.id : null,
|
||||
rootAXElement: rootAXElement != undefined ? rootAXElement.id : null,
|
||||
elements,
|
||||
AXelements,
|
||||
};
|
||||
};
|
||||
|
||||
static getAllNodes = async (
|
||||
root: Element,
|
||||
nodeMap: ElementMap,
|
||||
callClient: (method: ClientGetNodesCalls, params?: any) => Promise<any>,
|
||||
method: ClientGetNodesCalls,
|
||||
supportsMethod?: (method: ClientGetNodesCalls) => Promise<boolean>,
|
||||
): Promise<void> => {
|
||||
nodeMap[root.id] = root;
|
||||
if (
|
||||
root.children.length > 0 &&
|
||||
supportsMethod &&
|
||||
(await supportsMethod(method))
|
||||
) {
|
||||
await callClient(method, {ids: root.children}).then(
|
||||
async ({elements}: {elements: Array<Element>}) => {
|
||||
await Promise.all(
|
||||
elements.map(async (elem) => {
|
||||
await LayoutPlugin.getAllNodes(
|
||||
elem,
|
||||
nodeMap,
|
||||
callClient,
|
||||
method,
|
||||
supportsMethod,
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
static serializePersistedState: (
|
||||
persistedState: PersistedState,
|
||||
statusUpdate?: (msg: string) => void,
|
||||
idler?: Idler,
|
||||
) => Promise<string> = (
|
||||
persistedState: PersistedState,
|
||||
statusUpdate?: (msg: string) => void,
|
||||
_idler?: Idler,
|
||||
) => {
|
||||
statusUpdate && statusUpdate('Serializing Inspector Plugin...');
|
||||
return Promise.resolve(JSON.stringify(persistedState));
|
||||
};
|
||||
|
||||
static deserializePersistedState: (
|
||||
serializedString: string,
|
||||
) => PersistedState = (serializedString: string) => {
|
||||
return JSON.parse(serializedString);
|
||||
};
|
||||
|
||||
teardown() {
|
||||
this.state.visualizerWindow?.close();
|
||||
}
|
||||
|
||||
static defaultPersistedState = {
|
||||
rootElement: null,
|
||||
rootAXElement: null,
|
||||
elements: {},
|
||||
AXelements: {},
|
||||
};
|
||||
|
||||
state: State = {
|
||||
init: false,
|
||||
inTargetMode: false,
|
||||
inAXMode: false,
|
||||
inAlignmentMode: false,
|
||||
selectedElement: null,
|
||||
selectedAXElement: null,
|
||||
searchResults: null,
|
||||
visualizerWindow: null,
|
||||
highlightedElement: null,
|
||||
visualizerScreenshot: null,
|
||||
screenDimensions: null,
|
||||
};
|
||||
|
||||
private static isMylesInvoked = false;
|
||||
|
||||
init() {
|
||||
if (!this.props.persistedState) {
|
||||
// If the selected plugin from the previous session was layout, then while importing the flipper export, the redux store doesn't get updated in the first render, due to which the plugin crashes, as it has no persisted state
|
||||
this.props.setPersistedState(this.constructor.defaultPersistedState);
|
||||
}
|
||||
|
||||
if (this.client.isConnected) {
|
||||
// persist searchActive state when moving between plugins to prevent multiple
|
||||
// TouchOverlayViews since we can't edit the view heirarchy in onDisconnect
|
||||
this.client.call('isSearchActive').then(({isSearchActive}) => {
|
||||
this.setState({inTargetMode: isSearchActive});
|
||||
});
|
||||
|
||||
// disable target mode after
|
||||
this.client.subscribe('select', () => {
|
||||
if (this.state.inTargetMode) {
|
||||
this.onToggleTargetMode();
|
||||
}
|
||||
});
|
||||
|
||||
this.client.subscribe('resolvePath', (params: ClassFileParams) => {
|
||||
this.resolvePath(params);
|
||||
});
|
||||
|
||||
this.client.subscribe('openInIDE', (params: OpenFileParams) => {
|
||||
this.openInIDE(params);
|
||||
});
|
||||
}
|
||||
|
||||
// since the first launch of Myles might produce a lag (Myles daemon needs to start)
|
||||
// try to invoke Myles during the first launch of the Layout Plugin
|
||||
if (!LayoutPlugin.isMylesInvoked) {
|
||||
this.invokeMyles();
|
||||
LayoutPlugin.isMylesInvoked = true;
|
||||
}
|
||||
|
||||
if (this.props.isArchivedDevice) {
|
||||
Promise.resolve(this.device)
|
||||
.then((d) => {
|
||||
const handle = (d as ArchivedDevice).getArchivedScreenshotHandle();
|
||||
if (!handle) {
|
||||
throw new Error('No screenshot attached.');
|
||||
}
|
||||
return handle;
|
||||
})
|
||||
.then((handle) => getFlipperMediaCDN(handle, 'Image'))
|
||||
.then((url) => this.setState({visualizerScreenshot: url}))
|
||||
.catch((_) => {
|
||||
// Not all exports have screenshots. This is ok.
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
init: true,
|
||||
selectedElement:
|
||||
typeof this.props.deepLinkPayload === 'string'
|
||||
? this.props.deepLinkPayload
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
resolvePath = async (params: ClassFileParams) => {
|
||||
const paths = await IDEFileResolver.resolveFullPathsFromMyles(
|
||||
params.fileName,
|
||||
params.dirRoot,
|
||||
);
|
||||
const resolvedPath = IDEFileResolver.getBestPath(paths, params.className);
|
||||
if (this.client.isConnected) {
|
||||
this.client.send('setResolvedPath', {
|
||||
className: params.className,
|
||||
resolvedPath: resolvedPath,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
openInIDE = async (params: OpenFileParams) => {
|
||||
let ide: IDEType = Number(IDEType[params.ide]);
|
||||
if (Number.isNaN(ide)) {
|
||||
ide = IDEType.AS; // default value
|
||||
}
|
||||
IDEFileResolver.openInIDE(
|
||||
params.resolvedPath,
|
||||
ide,
|
||||
params.repo,
|
||||
params.lineNumber,
|
||||
);
|
||||
};
|
||||
|
||||
invokeMyles = async () => {
|
||||
await IDEFileResolver.resolveFullPathsFromMyles('.config', 'fbsource');
|
||||
};
|
||||
|
||||
onToggleTargetMode = () => {
|
||||
if (this.client.isConnected) {
|
||||
const inTargetMode = !this.state.inTargetMode;
|
||||
this.setState({inTargetMode});
|
||||
this.client.send('setSearchActive', {active: inTargetMode});
|
||||
}
|
||||
};
|
||||
|
||||
onToggleAXMode = () => {
|
||||
this.setState({inAXMode: !this.state.inAXMode});
|
||||
};
|
||||
|
||||
getClient(): PluginClient {
|
||||
return this.props.isArchivedDevice
|
||||
? new ProxyArchiveClient(this.props.persistedState, (id: string) => {
|
||||
this.setState({highlightedElement: id});
|
||||
})
|
||||
: this.client;
|
||||
}
|
||||
onToggleAlignmentMode = () => {
|
||||
if (this.state.selectedElement) {
|
||||
if (this.client.isConnected) {
|
||||
this.client.send('setHighlighted', {
|
||||
id: this.state.selectedElement,
|
||||
inAlignmentMode: !this.state.inAlignmentMode,
|
||||
});
|
||||
this.setState({inAlignmentMode: !this.state.inAlignmentMode});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onToggleVisualizer = () => {
|
||||
if (this.state.visualizerWindow) {
|
||||
this.state.visualizerWindow.close();
|
||||
} else {
|
||||
const screenDimensions = this.state.screenDimensions;
|
||||
if (!screenDimensions) {
|
||||
return;
|
||||
}
|
||||
const visualizerWindow = window.open(
|
||||
'',
|
||||
'visualizer',
|
||||
`width=${screenDimensions.width},height=${screenDimensions.height}`,
|
||||
);
|
||||
if (!visualizerWindow) {
|
||||
return;
|
||||
}
|
||||
visualizerWindow.onunload = () => {
|
||||
this.setState({visualizerWindow: null});
|
||||
};
|
||||
visualizerWindow.onresize = () => {
|
||||
this.setState({visualizerWindow: visualizerWindow});
|
||||
};
|
||||
visualizerWindow.onload = () => {
|
||||
this.setState({visualizerWindow: visualizerWindow});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
onDataValueChanged = (path: Array<string>, value: any) => {
|
||||
const id = this.state.inAXMode
|
||||
? this.state.selectedAXElement
|
||||
: this.state.selectedElement;
|
||||
this.props.logger.track('usage', 'layoutInspector:setData', {
|
||||
category: path[0],
|
||||
path: Array.from(path).splice(1).join(),
|
||||
...this.realClient.query,
|
||||
});
|
||||
this.client.call('setData', {
|
||||
id,
|
||||
path,
|
||||
value,
|
||||
ax: this.state.inAXMode,
|
||||
});
|
||||
};
|
||||
|
||||
getScreenDimensions(): {width: number; height: number} | null {
|
||||
if (this.state.screenDimensions) {
|
||||
return this.state.screenDimensions;
|
||||
}
|
||||
|
||||
requestIdleCallback(() => {
|
||||
// Walk the layout tree from root node down until a node with width and height is found.
|
||||
// Assume these are the dimensions of the screen.
|
||||
let elementId = this.props.persistedState.rootElement;
|
||||
while (elementId != null) {
|
||||
const element = this.props.persistedState.elements[elementId];
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
if (element.data.View?.width) {
|
||||
break;
|
||||
}
|
||||
elementId = element.children[0];
|
||||
}
|
||||
if (elementId == null) {
|
||||
return null;
|
||||
}
|
||||
const element = this.props.persistedState.elements[elementId];
|
||||
if (
|
||||
element == null ||
|
||||
typeof element.data.View?.width != 'object' ||
|
||||
typeof element.data.View?.height != 'object'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const screenDimensions = {
|
||||
width: element.data.View?.width.value,
|
||||
height: element.data.View?.height.value,
|
||||
};
|
||||
this.setState({screenDimensions});
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const inspectorProps = {
|
||||
client: this.getClient(),
|
||||
inAlignmentMode: this.state.inAlignmentMode,
|
||||
selectedElement: this.state.selectedElement,
|
||||
selectedAXElement: this.state.selectedAXElement,
|
||||
setPersistedState: this.props.setPersistedState,
|
||||
persistedState: this.props.persistedState,
|
||||
searchResults: this.state.searchResults,
|
||||
};
|
||||
|
||||
let element: Element | null = null;
|
||||
const {selectedAXElement, selectedElement, inAXMode} = this.state;
|
||||
if (inAXMode && selectedAXElement) {
|
||||
element = this.props.persistedState.AXelements[selectedAXElement];
|
||||
} else if (selectedElement) {
|
||||
element = this.props.persistedState.elements[selectedElement];
|
||||
}
|
||||
|
||||
const inspector = (
|
||||
<Inspector
|
||||
{...inspectorProps}
|
||||
onSelect={(selectedElement) => this.setState({selectedElement})}
|
||||
showsSidebar={!this.state.inAXMode}
|
||||
/>
|
||||
);
|
||||
|
||||
const axInspector = this.state.inAXMode ? (
|
||||
<Sidebar width={400} backgroundColor="white" position="right">
|
||||
<Inspector
|
||||
{...inspectorProps}
|
||||
onSelect={(selectedAXElement) => this.setState({selectedAXElement})}
|
||||
showsSidebar={true}
|
||||
ax
|
||||
/>
|
||||
</Sidebar>
|
||||
) : null;
|
||||
|
||||
const showAnalyzeYogaPerformanceButton = GK.get('flipper_yogaperformance');
|
||||
|
||||
const screenDimensions = this.getScreenDimensions();
|
||||
|
||||
if (!this.state.init) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Layout.Top>
|
||||
<Toolbar>
|
||||
{!this.props.isArchivedDevice && (
|
||||
<ToolbarIcon
|
||||
onClick={this.onToggleTargetMode}
|
||||
title="Toggle target mode"
|
||||
icon="target"
|
||||
active={this.state.inTargetMode}
|
||||
/>
|
||||
)}
|
||||
{this.realClient.query.os === 'Android' && (
|
||||
<ToolbarIcon
|
||||
onClick={this.onToggleAXMode}
|
||||
title="Toggle to see the accessibility hierarchy"
|
||||
icon="accessibility"
|
||||
active={this.state.inAXMode}
|
||||
/>
|
||||
)}
|
||||
{!this.props.isArchivedDevice && (
|
||||
<ToolbarIcon
|
||||
onClick={this.onToggleAlignmentMode}
|
||||
title="Toggle AlignmentMode to show alignment lines"
|
||||
icon="borders"
|
||||
active={this.state.inAlignmentMode}
|
||||
/>
|
||||
)}
|
||||
{this.props.isArchivedDevice && this.state.visualizerScreenshot && (
|
||||
<ToolbarIcon
|
||||
onClick={this.onToggleVisualizer}
|
||||
title="Toggle visual recreation of layout"
|
||||
icon="mobile"
|
||||
active={!!this.state.visualizerWindow}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Search
|
||||
client={this.getClient()}
|
||||
setPersistedState={this.props.setPersistedState}
|
||||
persistedState={this.props.persistedState}
|
||||
onSearchResults={(searchResults) =>
|
||||
this.setState({searchResults})
|
||||
}
|
||||
inAXMode={this.state.inAXMode}
|
||||
initialQuery={
|
||||
typeof this.props.deepLinkPayload === 'string'
|
||||
? this.props.deepLinkPayload
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</Toolbar>
|
||||
<Layout.Right>
|
||||
{inspector}
|
||||
{axInspector}
|
||||
</Layout.Right>
|
||||
</Layout.Top>
|
||||
|
||||
<DetailSidebar>
|
||||
<InspectorSidebar
|
||||
client={this.getClient()}
|
||||
realClient={this.realClient}
|
||||
element={element}
|
||||
onValueChanged={this.onDataValueChanged}
|
||||
logger={this.props.logger}
|
||||
/>
|
||||
{showAnalyzeYogaPerformanceButton &&
|
||||
element &&
|
||||
element.decoration === 'litho' ? (
|
||||
<Button
|
||||
icon={'share-external'}
|
||||
compact={true}
|
||||
style={{marginTop: 8, marginRight: 12}}
|
||||
onClick={() => {
|
||||
this.props.selectPlugin('YogaPerformance', element!.id);
|
||||
}}>
|
||||
Analyze Yoga Performance
|
||||
</Button>
|
||||
) : null}
|
||||
</DetailSidebar>
|
||||
{this.state.visualizerWindow &&
|
||||
screenDimensions &&
|
||||
(this.state.visualizerScreenshot ? (
|
||||
<VisualizerPortal
|
||||
container={this.state.visualizerWindow.document.body}
|
||||
elements={this.props.persistedState.elements}
|
||||
highlightedElement={this.state.highlightedElement}
|
||||
screenshotURL={this.state.visualizerScreenshot}
|
||||
screenDimensions={screenDimensions}
|
||||
/>
|
||||
) : (
|
||||
'Loading...'
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
25
desktop/plugins/public/layout/package.json
Normal file
25
desktop/plugins/public/layout/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-inspector",
|
||||
"id": "Inspector",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"dependencies": {
|
||||
"deep-equal": "^2.0.5",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^11.2.5"
|
||||
},
|
||||
"title": "Layout",
|
||||
"icon": "target",
|
||||
"bugs": {
|
||||
"email": "oncall+flipper@xmail.facebook.com",
|
||||
"url": "https://fb.workplace.com/groups/flippersupport/"
|
||||
}
|
||||
}
|
||||
276
desktop/plugins/public/leak_canary/index.tsx
Normal file
276
desktop/plugins/public/leak_canary/index.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 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,
|
||||
DataDescriptionType,
|
||||
} 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[];
|
||||
};
|
||||
|
||||
type LeakCanary2Report = {
|
||||
leaks: Leak2[];
|
||||
};
|
||||
|
||||
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;
|
||||
details?: string;
|
||||
};
|
||||
|
||||
export type Leak2 = {
|
||||
title: string;
|
||||
root: string;
|
||||
elements: {[key: string]: Element};
|
||||
retainedSize: string;
|
||||
details: 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) => {
|
||||
this._addNewLeaks(processLeaks(results.leaks));
|
||||
});
|
||||
|
||||
this.client.subscribe('reportLeak2', (results: LeakCanary2Report) => {
|
||||
this._addNewLeaks(results.leaks.map(this._adaptLeak2));
|
||||
});
|
||||
}
|
||||
|
||||
_addNewLeaks = (incomingLeaks: Leak[]) => {
|
||||
// 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 = incomingLeaks.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: leaks.length,
|
||||
});
|
||||
};
|
||||
|
||||
_adaptLeak2 = (leak: Leak2): Leak => {
|
||||
return {
|
||||
title: leak.title,
|
||||
root: leak.root,
|
||||
elements: leak.elements,
|
||||
elementsSimple: leak.elements,
|
||||
staticFields: {},
|
||||
instanceFields: {},
|
||||
retainedSize: leak.retainedSize,
|
||||
details: leak.details,
|
||||
};
|
||||
};
|
||||
|
||||
_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: DataDescriptionType; 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}>
|
||||
{instanceFields && (
|
||||
<Panel heading={'Instance'} floating={false} grow={false}>
|
||||
<ManagedDataInspector
|
||||
data={instanceFields}
|
||||
expandRoot={true}
|
||||
extractValue={this._extractValue}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
{staticFields && (
|
||||
<Panel heading={'Static'} floating={false} grow={false}>
|
||||
<ManagedDataInspector
|
||||
data={staticFields}
|
||||
expandRoot={true}
|
||||
extractValue={this._extractValue}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
{leak.details && (
|
||||
<Panel heading={'Details'} floating={false} grow={false}>
|
||||
<pre>{leak.details}</pre>
|
||||
</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
|
||||
key={idx}
|
||||
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);
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
19
desktop/plugins/public/leak_canary/package.json
Normal file
19
desktop/plugins/public/leak_canary/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-leak-canary",
|
||||
"id": "LeakCanary",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"dependencies": {},
|
||||
"devDependencies": {},
|
||||
"icon": "bird",
|
||||
"title": "LeakCanary",
|
||||
"bugs": {
|
||||
"email": ""
|
||||
}
|
||||
}
|
||||
262
desktop/plugins/public/leak_canary/processLeakString.tsx
Normal file
262
desktop/plugins/public/leak_canary/processLeakString.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 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 {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;
|
||||
}
|
||||
176
desktop/plugins/public/logs/__tests__/logs.node.tsx
Normal file
176
desktop/plugins/public/logs/__tests__/logs.node.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 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 {sleep, TestUtils} from 'flipper-plugin';
|
||||
import * as LogsPlugin from '../index';
|
||||
|
||||
const entry1 = {
|
||||
date: new Date(1611854112859),
|
||||
message: 'test1',
|
||||
pid: 0,
|
||||
tag: 'test',
|
||||
tid: 1,
|
||||
type: 'error',
|
||||
app: 'X',
|
||||
} as const;
|
||||
const entry2 = {
|
||||
date: new Date(1611854117859),
|
||||
message: 'test2',
|
||||
pid: 2,
|
||||
tag: 'test',
|
||||
tid: 3,
|
||||
type: 'warn',
|
||||
app: 'Y',
|
||||
} as const;
|
||||
const entry3 = {
|
||||
date: new Date(1611854112859),
|
||||
message: 'test3',
|
||||
pid: 0,
|
||||
tag: 'test',
|
||||
tid: 1,
|
||||
type: 'error',
|
||||
app: 'X',
|
||||
} as const;
|
||||
|
||||
test('it will merge equal rows', () => {
|
||||
const {instance, sendLogEntry} = TestUtils.startDevicePlugin(LogsPlugin);
|
||||
|
||||
sendLogEntry(entry1);
|
||||
sendLogEntry(entry2);
|
||||
sendLogEntry({
|
||||
...entry2,
|
||||
date: new Date(1611954117859),
|
||||
});
|
||||
sendLogEntry(entry3);
|
||||
|
||||
expect(instance.rows.records()).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"app": "X",
|
||||
"count": 1,
|
||||
"date": 2021-01-28T17:15:12.859Z,
|
||||
"message": "test1",
|
||||
"pid": 0,
|
||||
"tag": "test",
|
||||
"tid": 1,
|
||||
"type": "error",
|
||||
},
|
||||
Object {
|
||||
"app": "Y",
|
||||
"count": 2,
|
||||
"date": 2021-01-28T17:15:17.859Z,
|
||||
"message": "test2",
|
||||
"pid": 2,
|
||||
"tag": "test",
|
||||
"tid": 3,
|
||||
"type": "warn",
|
||||
},
|
||||
Object {
|
||||
"app": "X",
|
||||
"count": 1,
|
||||
"date": 2021-01-28T17:15:12.859Z,
|
||||
"message": "test3",
|
||||
"pid": 0,
|
||||
"tag": "test",
|
||||
"tid": 1,
|
||||
"type": "error",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('it supports deeplink and select nodes + navigating to bottom', async () => {
|
||||
const {
|
||||
instance,
|
||||
sendLogEntry,
|
||||
triggerDeepLink,
|
||||
act,
|
||||
triggerMenuEntry,
|
||||
} = TestUtils.renderDevicePlugin(LogsPlugin);
|
||||
|
||||
sendLogEntry(entry1);
|
||||
sendLogEntry(entry2);
|
||||
sendLogEntry(entry3);
|
||||
|
||||
expect(instance.tableManagerRef).not.toBeUndefined();
|
||||
expect(instance.tableManagerRef.current?.getSelectedItems()).toEqual([]);
|
||||
|
||||
act(() => {
|
||||
triggerDeepLink('test2');
|
||||
});
|
||||
|
||||
await sleep(1000);
|
||||
|
||||
expect(instance.tableManagerRef.current?.getSelectedItems()).toEqual([
|
||||
{
|
||||
...entry2,
|
||||
count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
triggerMenuEntry('goToBottom');
|
||||
});
|
||||
expect(instance.tableManagerRef.current?.getSelectedItems()).toEqual([
|
||||
{
|
||||
...entry3,
|
||||
count: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('export / import plugin does work', async () => {
|
||||
const {
|
||||
instance,
|
||||
exportStateAsync,
|
||||
sendLogEntry,
|
||||
} = TestUtils.startDevicePlugin(LogsPlugin);
|
||||
|
||||
sendLogEntry(entry1);
|
||||
sendLogEntry(entry2);
|
||||
|
||||
const data = await exportStateAsync();
|
||||
expect(data).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"logs": Array [
|
||||
Object {
|
||||
"app": "X",
|
||||
"count": 1,
|
||||
"date": 2021-01-28T17:15:12.859Z,
|
||||
"message": "test1",
|
||||
"pid": 0,
|
||||
"tag": "test",
|
||||
"tid": 1,
|
||||
"type": "error",
|
||||
},
|
||||
Object {
|
||||
"app": "Y",
|
||||
"count": 1,
|
||||
"date": 2021-01-28T17:15:17.859Z,
|
||||
"message": "test2",
|
||||
"pid": 2,
|
||||
"tag": "test",
|
||||
"tid": 3,
|
||||
"type": "warn",
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
expect(instance.rows.size).toBe(2);
|
||||
|
||||
// Run a second import
|
||||
{
|
||||
const {exportStateAsync} = TestUtils.startDevicePlugin(LogsPlugin, {
|
||||
initialState: data,
|
||||
});
|
||||
|
||||
expect(await exportStateAsync()).toEqual(data);
|
||||
}
|
||||
});
|
||||
249
desktop/plugins/public/logs/index.tsx
Normal file
249
desktop/plugins/public/logs/index.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 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 {
|
||||
DevicePluginClient,
|
||||
DeviceLogEntry,
|
||||
usePlugin,
|
||||
createDataSource,
|
||||
DataTable,
|
||||
DataTableColumn,
|
||||
theme,
|
||||
DataTableManager,
|
||||
createState,
|
||||
useValue,
|
||||
DataFormatter,
|
||||
} from 'flipper-plugin';
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import React, {createRef, CSSProperties} from 'react';
|
||||
import {Badge, Button} from 'antd';
|
||||
|
||||
import {baseRowStyle, logTypes} from './logTypes';
|
||||
|
||||
export type ExtendedLogEntry = DeviceLogEntry & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
function createColumnConfig(
|
||||
_os: 'iOS' | 'Android' | 'Metro',
|
||||
): DataTableColumn<ExtendedLogEntry>[] {
|
||||
return [
|
||||
{
|
||||
key: 'type',
|
||||
title: '',
|
||||
width: 30,
|
||||
filters: Object.entries(logTypes).map(([value, config]) => ({
|
||||
label: config.label,
|
||||
value,
|
||||
enabled: config.enabled,
|
||||
})),
|
||||
onRender(entry) {
|
||||
return entry.count > 1 ? (
|
||||
<Badge
|
||||
count={entry.count}
|
||||
size="small"
|
||||
style={{
|
||||
marginTop: 4,
|
||||
color: theme.white,
|
||||
background:
|
||||
(logTypes[entry.type]?.style as any)?.color ??
|
||||
theme.textColorSecondary,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
logTypes[entry.type]?.icon
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
title: 'Time',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
key: 'pid',
|
||||
title: 'PID',
|
||||
width: 60,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: 'tid',
|
||||
title: 'TID',
|
||||
width: 60,
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
key: 'tag',
|
||||
title: 'Tag',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
key: 'app',
|
||||
title: 'App',
|
||||
width: 160,
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
title: 'Message',
|
||||
wrap: true,
|
||||
formatters: [
|
||||
DataFormatter.truncate(400),
|
||||
DataFormatter.prettyPrintJson,
|
||||
DataFormatter.linkify,
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getRowStyle(entry: DeviceLogEntry): CSSProperties | undefined {
|
||||
return (logTypes[entry.type]?.style as any) ?? baseRowStyle;
|
||||
}
|
||||
|
||||
export function devicePlugin(client: DevicePluginClient) {
|
||||
const rows = createDataSource<ExtendedLogEntry>([], {
|
||||
limit: 200000,
|
||||
persist: 'logs',
|
||||
});
|
||||
const isPaused = createState(true);
|
||||
const tableManagerRef = createRef<
|
||||
undefined | DataTableManager<ExtendedLogEntry>
|
||||
>();
|
||||
|
||||
client.onDeepLink((payload: unknown) => {
|
||||
if (typeof payload === 'string') {
|
||||
// timeout as we want to await restoring any previous scroll positin first, then scroll to the
|
||||
setTimeout(() => {
|
||||
let hasMatch = false;
|
||||
rows.view.output(0, rows.view.size).forEach((row, index) => {
|
||||
if (row.message.includes(payload)) {
|
||||
tableManagerRef.current?.selectItem(index, hasMatch);
|
||||
hasMatch = true;
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
client.addMenuEntry(
|
||||
{
|
||||
action: 'clear',
|
||||
handler: clearLogs,
|
||||
},
|
||||
{
|
||||
action: 'createPaste',
|
||||
handler: createPaste,
|
||||
},
|
||||
{
|
||||
action: 'goToBottom',
|
||||
handler: goToBottom,
|
||||
},
|
||||
);
|
||||
|
||||
let logDisposer: (() => void) | undefined;
|
||||
|
||||
function resumePause() {
|
||||
if (isPaused.get() && client.device.isConnected) {
|
||||
// start listening to the logs
|
||||
isPaused.set(false);
|
||||
logDisposer = client.device.onLogEntry((entry: DeviceLogEntry) => {
|
||||
const lastIndex = rows.size - 1;
|
||||
const previousRow = rows.get(lastIndex);
|
||||
if (
|
||||
previousRow &&
|
||||
previousRow.message === entry.message &&
|
||||
previousRow.tag === entry.tag &&
|
||||
previousRow.type === entry.type
|
||||
) {
|
||||
rows.update(lastIndex, {
|
||||
...previousRow,
|
||||
count: previousRow.count + 1,
|
||||
});
|
||||
} else {
|
||||
rows.append({
|
||||
...entry,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logDisposer?.();
|
||||
isPaused.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
// Non public Android specific api
|
||||
(client.device.realDevice as any)?.clearLogs?.();
|
||||
rows.clear();
|
||||
tableManagerRef.current?.clearSelection();
|
||||
}
|
||||
|
||||
function createPaste() {
|
||||
let selection = tableManagerRef.current?.getSelectedItems();
|
||||
if (!selection?.length) {
|
||||
selection = rows.view.output(0, rows.view.size);
|
||||
}
|
||||
if (selection?.length) {
|
||||
client.createPaste(JSON.stringify(selection, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
function goToBottom() {
|
||||
tableManagerRef?.current?.selectItem(rows.view.size - 1);
|
||||
}
|
||||
|
||||
// start listening to the logs
|
||||
resumePause();
|
||||
|
||||
const columns = createColumnConfig(client.device.os as any);
|
||||
|
||||
return {
|
||||
columns,
|
||||
isConnected: client.device.isConnected,
|
||||
isPaused,
|
||||
tableManagerRef,
|
||||
rows,
|
||||
clearLogs,
|
||||
resumePause,
|
||||
};
|
||||
}
|
||||
|
||||
export function Component() {
|
||||
const plugin = usePlugin(devicePlugin);
|
||||
const paused = useValue(plugin.isPaused);
|
||||
return (
|
||||
<DataTable<ExtendedLogEntry>
|
||||
dataSource={plugin.rows}
|
||||
columns={plugin.columns}
|
||||
autoScroll
|
||||
onRowStyle={getRowStyle}
|
||||
extraActions={
|
||||
plugin.isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
title={`Click to ${paused ? 'resume' : 'pause'} the log stream`}
|
||||
danger={paused}
|
||||
onClick={plugin.resumePause}>
|
||||
{paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
|
||||
</Button>
|
||||
<Button title="Clear logs" onClick={plugin.clearLogs}>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
tableManagerRef={plugin.tableManagerRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
78
desktop/plugins/public/logs/logTypes.tsx
Normal file
78
desktop/plugins/public/logs/logTypes.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 {theme} from 'flipper-plugin';
|
||||
import {WarningFilled, CloseCircleFilled} from '@ant-design/icons';
|
||||
import React, {CSSProperties} from 'react';
|
||||
|
||||
const iconStyle = {
|
||||
fontSize: '16px',
|
||||
};
|
||||
|
||||
export const baseRowStyle = {
|
||||
...theme.monospace,
|
||||
};
|
||||
|
||||
export const logTypes: {
|
||||
[level: string]: {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
style?: CSSProperties;
|
||||
enabled: boolean;
|
||||
};
|
||||
} = {
|
||||
verbose: {
|
||||
label: 'Verbose',
|
||||
style: {
|
||||
...baseRowStyle,
|
||||
color: theme.textColorSecondary,
|
||||
},
|
||||
enabled: false,
|
||||
},
|
||||
debug: {
|
||||
label: 'Debug',
|
||||
style: {
|
||||
...baseRowStyle,
|
||||
color: theme.textColorSecondary,
|
||||
},
|
||||
enabled: false,
|
||||
},
|
||||
info: {
|
||||
label: 'Info',
|
||||
enabled: true,
|
||||
},
|
||||
warn: {
|
||||
label: 'Warn',
|
||||
style: {
|
||||
...baseRowStyle,
|
||||
color: theme.warningColor,
|
||||
},
|
||||
icon: <WarningFilled style={iconStyle} />,
|
||||
enabled: true,
|
||||
},
|
||||
error: {
|
||||
label: 'Error',
|
||||
style: {
|
||||
...baseRowStyle,
|
||||
color: theme.errorColor,
|
||||
},
|
||||
icon: <CloseCircleFilled style={iconStyle} />,
|
||||
enabled: true,
|
||||
},
|
||||
fatal: {
|
||||
label: 'Fatal',
|
||||
style: {
|
||||
...baseRowStyle,
|
||||
background: theme.errorColor,
|
||||
color: theme.white,
|
||||
},
|
||||
icon: <CloseCircleFilled style={iconStyle} />,
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
44
desktop/plugins/public/logs/package.json
Normal file
44
desktop/plugins/public/logs/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-device-logs",
|
||||
"id": "DeviceLogs",
|
||||
"pluginType": "device",
|
||||
"supportedDevices": [
|
||||
{
|
||||
"os": "Android",
|
||||
"type": "emulator"
|
||||
},
|
||||
{
|
||||
"os": "Android",
|
||||
"type": "physical"
|
||||
},
|
||||
{
|
||||
"os": "iOS",
|
||||
"type": "emulator"
|
||||
},
|
||||
{
|
||||
"os": "iOS",
|
||||
"type": "physical"
|
||||
},
|
||||
{
|
||||
"os": "Metro"
|
||||
}
|
||||
],
|
||||
"version": "0.0.0",
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"dependencies": {},
|
||||
"peerDependencies": {
|
||||
"flipper-plugin": "*"
|
||||
},
|
||||
"title": "Logs",
|
||||
"icon": "arrow-right",
|
||||
"bugs": {
|
||||
"email": "oncall+flipper@xmail.facebook.com",
|
||||
"url": "https://fb.workplace.com/groups/flippersupport/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 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 {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);
|
||||
});
|
||||
64
desktop/plugins/public/navigation/__tests__/testURI.node.tsx
Normal file
64
desktop/plugins/public/navigation/__tests__/testURI.node.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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 {
|
||||
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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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/public/navigation/components/IconButton.tsx
Normal file
65
desktop/plugins/public/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>
|
||||
);
|
||||
}
|
||||
@@ -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,99 @@
|
||||
/**
|
||||
* 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 {Modal, Button, Alert, Input, Typography} from 'antd';
|
||||
import {Layout} from 'flipper-plugin';
|
||||
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>;
|
||||
onHide: () => void;
|
||||
onSubmit: (uri: URI) => void;
|
||||
};
|
||||
|
||||
export default (props: Props) => {
|
||||
const {onHide, onSubmit, uri, requiredParameters} = props;
|
||||
const {isValid, values, setValuesArray} = useRequiredParameterFormValidator(
|
||||
requiredParameters,
|
||||
);
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
onCancel={onHide}
|
||||
title="Provide bookmark details"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onHide();
|
||||
setValuesArray([]);
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type={'primary'}
|
||||
onClick={() => {
|
||||
onSubmit(replaceRequiredParametersWithValues(uri, values));
|
||||
onHide();
|
||||
}}
|
||||
disabled={!isValid}>
|
||||
Submit
|
||||
</Button>
|
||||
</>
|
||||
}>
|
||||
<Layout.Container gap>
|
||||
<Alert
|
||||
type="info"
|
||||
message="This uri has required parameters denoted by '{parameter}'}."
|
||||
/>
|
||||
|
||||
{requiredParameters.map((paramater, idx) => (
|
||||
<div key={idx}>
|
||||
<Input
|
||||
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) ? (
|
||||
<Alert type="error" message="Parameter must be a number" />
|
||||
) : null}
|
||||
{values[idx] &&
|
||||
parameterIsBooleanType(paramater) &&
|
||||
!validateParameter(values[idx], paramater) ? (
|
||||
<Alert
|
||||
type="error"
|
||||
message="Parameter must be either 'true' or 'false'"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<Typography.Text code>{liveEdit(uri, values)}</Typography.Text>
|
||||
</Layout.Container>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -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/public/navigation/components/SearchBar.tsx
Normal file
166
desktop/plugins/public/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;
|
||||
94
desktop/plugins/public/navigation/components/Timeline.tsx
Normal file
94
desktop/plugins/public/navigation/components/Timeline.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 {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 key={idx}>
|
||||
<NavigationInfoBox
|
||||
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/public/navigation/components/index.tsx
Normal file
18
desktop/plugins/public/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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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 {useMemo, useState} from 'react';
|
||||
import {validateParameter} from '../util/uri';
|
||||
|
||||
export const useRequiredParameterFormValidator = (
|
||||
requiredParameters: Array<string>,
|
||||
) => {
|
||||
const [values, setValuesArray] = useState<Array<string>>(
|
||||
requiredParameters.map(() => ''),
|
||||
);
|
||||
const isValid = useMemo(() => {
|
||||
if (requiredParameters.length != values.length) {
|
||||
setValuesArray(requiredParameters.map(() => ''));
|
||||
}
|
||||
if (
|
||||
values.every((value, idx) =>
|
||||
validateParameter(value, requiredParameters[idx]),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, [requiredParameters, values]);
|
||||
return {isValid, values, setValuesArray};
|
||||
};
|
||||
255
desktop/plugins/public/navigation/index.tsx
Normal file
255
desktop/plugins/public/navigation/index.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 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 {bufferToBlob} from 'flipper';
|
||||
import {
|
||||
BookmarksSidebar,
|
||||
SaveBookmarkDialog,
|
||||
SearchBar,
|
||||
Timeline,
|
||||
RequiredParametersDialog,
|
||||
} from './components';
|
||||
import {
|
||||
removeBookmarkFromDB,
|
||||
readBookmarksFromDB,
|
||||
writeBookmarkToDB,
|
||||
} from './util/indexedDB';
|
||||
import {
|
||||
appMatchPatternsToAutoCompleteProvider,
|
||||
bookmarksToAutoCompleteProvider,
|
||||
} from './util/autoCompleteProvider';
|
||||
import {getAppMatchPatterns} from './util/appMatchPatterns';
|
||||
import {getRequiredParameters, filterOptionalParameters} from './util/uri';
|
||||
import {
|
||||
Bookmark,
|
||||
NavigationEvent,
|
||||
AppMatchPattern,
|
||||
URI,
|
||||
RawNavigationEvent,
|
||||
} from './types';
|
||||
import React, {useMemo} from 'react';
|
||||
import {
|
||||
PluginClient,
|
||||
createState,
|
||||
useValue,
|
||||
usePlugin,
|
||||
Layout,
|
||||
renderReactRoot,
|
||||
} from 'flipper-plugin';
|
||||
|
||||
export type State = {
|
||||
shouldShowSaveBookmarkDialog: boolean;
|
||||
shouldShowURIErrorDialog: boolean;
|
||||
saveBookmarkURI: URI | null;
|
||||
requiredParameters: Array<string>;
|
||||
};
|
||||
|
||||
type Events = {
|
||||
nav_event: RawNavigationEvent;
|
||||
};
|
||||
|
||||
type Methods = {
|
||||
navigate_to(params: {url: string}): Promise<void>;
|
||||
};
|
||||
|
||||
export type NavigationPlugin = ReturnType<typeof plugin>;
|
||||
|
||||
export function plugin(client: PluginClient<Events, Methods>) {
|
||||
const bookmarks = createState(new Map<URI, Bookmark>(), {
|
||||
persist: 'bookmarks',
|
||||
});
|
||||
const navigationEvents = createState<NavigationEvent[]>([], {
|
||||
persist: 'navigationEvents',
|
||||
});
|
||||
const appMatchPatterns = createState<AppMatchPattern[]>([], {
|
||||
persist: 'appMatchPatterns',
|
||||
});
|
||||
const currentURI = createState('');
|
||||
const shouldShowSaveBookmarkDialog = createState(false);
|
||||
const saveBookmarkURI = createState<null | string>(null);
|
||||
|
||||
client.onMessage('nav_event', async (payload) => {
|
||||
const navigationEvent: NavigationEvent = {
|
||||
uri: payload.uri === undefined ? null : decodeURIComponent(payload.uri),
|
||||
date: payload.date ? new Date(payload.date) : new Date(),
|
||||
className: payload.class === undefined ? null : payload.class,
|
||||
screenshot: null,
|
||||
};
|
||||
|
||||
if (navigationEvent.uri) currentURI.set(navigationEvent.uri);
|
||||
|
||||
navigationEvents.update((draft) => {
|
||||
draft.unshift(navigationEvent);
|
||||
});
|
||||
|
||||
const screenshot: Buffer = await client.device.realDevice.screenshot();
|
||||
const blobURL = URL.createObjectURL(bufferToBlob(screenshot));
|
||||
// this process is async, make sure we update the correct one..
|
||||
const navigationEventIndex = navigationEvents
|
||||
.get()
|
||||
.indexOf(navigationEvent);
|
||||
if (navigationEventIndex !== -1) {
|
||||
navigationEvents.update((draft) => {
|
||||
draft[navigationEventIndex].screenshot = blobURL;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
getAppMatchPatterns(client.appId, client.device.realDevice)
|
||||
.then((patterns) => {
|
||||
appMatchPatterns.set(patterns);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[Navigation] Failed to find appMatchPatterns', e);
|
||||
});
|
||||
|
||||
readBookmarksFromDB().then((bookmarksData) => {
|
||||
bookmarks.set(bookmarksData);
|
||||
});
|
||||
|
||||
function navigateTo(query: string) {
|
||||
const filteredQuery = filterOptionalParameters(query);
|
||||
currentURI.set(filteredQuery);
|
||||
const params = getRequiredParameters(filteredQuery);
|
||||
if (params.length === 0) {
|
||||
if (client.appName === 'Facebook' && client.device.os === 'iOS') {
|
||||
// use custom navigate_to event for Wilde
|
||||
client.send('navigate_to', {
|
||||
url: filterOptionalParameters(filteredQuery),
|
||||
});
|
||||
} else {
|
||||
client.device.realDevice.navigateToLocation(
|
||||
filterOptionalParameters(filteredQuery),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
renderReactRoot((unmount) => (
|
||||
<RequiredParametersDialog
|
||||
onHide={unmount}
|
||||
uri={filteredQuery}
|
||||
requiredParameters={params}
|
||||
onSubmit={navigateTo}
|
||||
/>
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
function onFavorite(uri: string) {
|
||||
shouldShowSaveBookmarkDialog.set(true);
|
||||
saveBookmarkURI.set(uri);
|
||||
}
|
||||
|
||||
function addBookmark(bookmark: Bookmark) {
|
||||
const newBookmark = {
|
||||
uri: bookmark.uri,
|
||||
commonName: bookmark.commonName,
|
||||
};
|
||||
|
||||
bookmarks.update((draft) => {
|
||||
draft.set(newBookmark.uri, newBookmark);
|
||||
});
|
||||
writeBookmarkToDB(newBookmark);
|
||||
}
|
||||
|
||||
function removeBookmark(uri: string) {
|
||||
bookmarks.update((draft) => {
|
||||
draft.delete(uri);
|
||||
});
|
||||
removeBookmarkFromDB(uri);
|
||||
}
|
||||
|
||||
return {
|
||||
navigateTo,
|
||||
onFavorite,
|
||||
addBookmark,
|
||||
removeBookmark,
|
||||
bookmarks,
|
||||
saveBookmarkURI,
|
||||
shouldShowSaveBookmarkDialog,
|
||||
appMatchPatterns,
|
||||
navigationEvents,
|
||||
currentURI,
|
||||
getAutoCompleteAppMatchPatterns(
|
||||
query: string,
|
||||
bookmarks: Map<string, Bookmark>,
|
||||
appMatchPatterns: AppMatchPattern[],
|
||||
limit: number,
|
||||
): AppMatchPattern[] {
|
||||
const q = query.toLowerCase();
|
||||
const results: AppMatchPattern[] = [];
|
||||
for (const item of appMatchPatterns) {
|
||||
if (
|
||||
!bookmarks.has(item.pattern) &&
|
||||
(item.className.toLowerCase().includes(q) ||
|
||||
item.pattern.toLowerCase().includes(q))
|
||||
) {
|
||||
results.push(item);
|
||||
if (--limit < 1) break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function Component() {
|
||||
const instance = usePlugin(plugin);
|
||||
const bookmarks = useValue(instance.bookmarks);
|
||||
const appMatchPatterns = useValue(instance.appMatchPatterns);
|
||||
const saveBookmarkURI = useValue(instance.saveBookmarkURI);
|
||||
const shouldShowSaveBookmarkDialog = useValue(
|
||||
instance.shouldShowSaveBookmarkDialog,
|
||||
);
|
||||
const currentURI = useValue(instance.currentURI);
|
||||
const navigationEvents = useValue(instance.navigationEvents);
|
||||
|
||||
const autoCompleteProviders = useMemo(
|
||||
() => [
|
||||
bookmarksToAutoCompleteProvider(bookmarks),
|
||||
appMatchPatternsToAutoCompleteProvider(appMatchPatterns),
|
||||
],
|
||||
[bookmarks, appMatchPatterns],
|
||||
);
|
||||
return (
|
||||
<Layout.Container>
|
||||
<SearchBar
|
||||
providers={autoCompleteProviders}
|
||||
bookmarks={bookmarks}
|
||||
onNavigate={instance.navigateTo}
|
||||
onFavorite={instance.onFavorite}
|
||||
uriFromAbove={currentURI}
|
||||
/>
|
||||
<Timeline
|
||||
bookmarks={bookmarks}
|
||||
events={navigationEvents}
|
||||
onNavigate={instance.navigateTo}
|
||||
onFavorite={instance.onFavorite}
|
||||
/>
|
||||
<BookmarksSidebar
|
||||
bookmarks={bookmarks}
|
||||
onRemove={instance.removeBookmark}
|
||||
onNavigate={instance.navigateTo}
|
||||
/>
|
||||
<SaveBookmarkDialog
|
||||
shouldShow={shouldShowSaveBookmarkDialog}
|
||||
uri={saveBookmarkURI}
|
||||
onHide={() => {
|
||||
instance.shouldShowSaveBookmarkDialog.set(false);
|
||||
}}
|
||||
edit={saveBookmarkURI != null ? bookmarks.has(saveBookmarkURI) : false}
|
||||
onSubmit={instance.addBookmark}
|
||||
onRemove={instance.removeBookmark}
|
||||
/>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
|
||||
/* @scarf-info: do not remove, more info: https://fburl.com/scarf */
|
||||
/* @scarf-generated: flipper-plugin index.js.template 0bfa32e5-fb15-4705-81f8-86260a1f3f8e */
|
||||
21
desktop/plugins/public/navigation/package.json
Normal file
21
desktop/plugins/public/navigation/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-navigation",
|
||||
"id": "Navigation",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/bundle.js",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"title": "Navigation",
|
||||
"icon": "directions",
|
||||
"bugs": {
|
||||
"email": "beneloca@fb.com"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"flipper-plugin": "*",
|
||||
"antd": "*"
|
||||
}
|
||||
}
|
||||
8
desktop/plugins/public/navigation/tsconfig.json
Normal file
8
desktop/plugins/public/navigation/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"rootDir": ".",
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
45
desktop/plugins/public/navigation/types.tsx
Normal file
45
desktop/plugins/public/navigation/types.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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 RawNavigationEvent = {
|
||||
date: string | undefined;
|
||||
uri: URI | undefined;
|
||||
class: string | undefined;
|
||||
screenshot: string | undefined;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
61
desktop/plugins/public/navigation/util/appMatchPatterns.tsx
Normal file
61
desktop/plugins/public/navigation/util/appMatchPatterns.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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';
|
||||
import {remote} from 'electron';
|
||||
|
||||
let patternsPath: string | undefined;
|
||||
|
||||
function getPatternsBasePath() {
|
||||
return (patternsPath =
|
||||
patternsPath ?? path.join(remote.app.getAppPath(), 'facebook'));
|
||||
}
|
||||
|
||||
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 patternsFilePath = path.join(getPatternsBasePath(), filename);
|
||||
fs.readFile(patternsFilePath, (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(JSON.parse(data.toString()));
|
||||
}
|
||||
});
|
||||
} else if (appName != null) {
|
||||
console.log('No rule for app ' + appName);
|
||||
resolve([]);
|
||||
} else {
|
||||
reject(new Error('selectedApp was null'));
|
||||
}
|
||||
});
|
||||
};
|
||||
103
desktop/plugins/public/navigation/util/autoCompleteProvider.tsx
Normal file
103
desktop/plugins/public/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/public/navigation/util/indexedDB.tsx
Normal file
104
desktop/plugins/public/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<void>((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 removeBookmarkFromDB: (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);
|
||||
});
|
||||
};
|
||||
91
desktop/plugins/public/navigation/util/uri.tsx
Normal file
91
desktop/plugins/public/navigation/util/uri.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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, '');
|
||||
};
|
||||
303
desktop/plugins/public/network/ManageMockResponsePanel.tsx
Normal file
303
desktop/plugins/public/network/ManageMockResponsePanel.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 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,
|
||||
ManagedTable,
|
||||
Text,
|
||||
Glyph,
|
||||
styled,
|
||||
colors,
|
||||
Panel,
|
||||
} from 'flipper';
|
||||
import React, {useContext, useState, useMemo, useEffect} from 'react';
|
||||
import {Route, Request, Response} from './types';
|
||||
import {MockResponseDetails} from './MockResponseDetails';
|
||||
import {NetworkRouteContext} from './index';
|
||||
import {RequestId} from './types';
|
||||
import {message, Checkbox, Modal, Tooltip} from 'antd';
|
||||
import {NUX, Layout} from 'flipper-plugin';
|
||||
|
||||
type Props = {
|
||||
routes: {[id: string]: Route};
|
||||
highlightedRows: Set<string> | null | undefined;
|
||||
requests: {[id: string]: Request};
|
||||
responses: {[id: string]: Response};
|
||||
};
|
||||
|
||||
const ColumnSizes = {route: 'flex'};
|
||||
|
||||
const Columns = {route: {value: 'Route', resizable: false}};
|
||||
|
||||
const TextEllipsis = styled(Text)({
|
||||
overflowX: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
lineHeight: '18px',
|
||||
paddingTop: 4,
|
||||
display: 'block',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
const Icon = styled(Glyph)({
|
||||
marginTop: 5,
|
||||
marginRight: 8,
|
||||
});
|
||||
|
||||
// return ids that have the same pair of requestUrl and method; this will return only the duplicate
|
||||
function _duplicateIds(routes: {[id: string]: Route}): Array<RequestId> {
|
||||
const idSet: {[id: string]: {[method: string]: boolean}} = {};
|
||||
return Object.entries(routes).reduce((acc: Array<RequestId>, [id, route]) => {
|
||||
if (idSet.hasOwnProperty(route.requestUrl)) {
|
||||
if (idSet[route.requestUrl].hasOwnProperty(route.requestMethod)) {
|
||||
return acc.concat(id);
|
||||
}
|
||||
idSet[route.requestUrl] = {
|
||||
...idSet[route.requestUrl],
|
||||
[route.requestMethod]: true,
|
||||
};
|
||||
return acc;
|
||||
} else {
|
||||
idSet[route.requestUrl] = {[route.requestMethod]: true};
|
||||
return acc;
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
|
||||
function _buildRows(
|
||||
routes: {[id: string]: Route},
|
||||
duplicatedIds: Array<string>,
|
||||
handleRemoveId: (id: string) => void,
|
||||
handleEnableId: (id: string) => void,
|
||||
) {
|
||||
return Object.entries(routes).map(([id, route]) => ({
|
||||
columns: {
|
||||
route: {
|
||||
value: (
|
||||
<RouteRow
|
||||
key={id}
|
||||
text={route.requestUrl}
|
||||
showWarning={duplicatedIds.includes(id)}
|
||||
handleRemoveId={() => handleRemoveId(id)}
|
||||
handleEnableId={() => handleEnableId(id)}
|
||||
enabled={route.enabled}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
key: id,
|
||||
}));
|
||||
}
|
||||
|
||||
function RouteRow(props: {
|
||||
text: string;
|
||||
showWarning: boolean;
|
||||
handleRemoveId: () => void;
|
||||
handleEnableId: () => void;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const tip = props.enabled
|
||||
? 'Un-check to disable mock route'
|
||||
: 'Check to enable mock route';
|
||||
return (
|
||||
<Layout.Horizontal gap>
|
||||
<Tooltip title={tip} mouseEnterDelay={1.1}>
|
||||
<Checkbox
|
||||
onClick={props.handleEnableId}
|
||||
checked={props.enabled}></Checkbox>
|
||||
</Tooltip>
|
||||
<Tooltip title="Click to delete mock route" mouseEnterDelay={1.1}>
|
||||
<Layout.Horizontal onClick={props.handleRemoveId}>
|
||||
<Icon name="cross-circle" color={colors.red} />
|
||||
</Layout.Horizontal>
|
||||
</Tooltip>
|
||||
{props.showWarning && (
|
||||
<Icon name="caution-triangle" color={colors.yellow} />
|
||||
)}
|
||||
{props.text.length === 0 ? (
|
||||
<TextEllipsis style={{color: colors.blackAlpha50}}>
|
||||
untitled
|
||||
</TextEllipsis>
|
||||
) : (
|
||||
<TextEllipsis>{props.text}</TextEllipsis>
|
||||
)}
|
||||
</Layout.Horizontal>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagedMockResponseRightPanel(props: {
|
||||
id: string;
|
||||
route: Route;
|
||||
isDuplicated: boolean;
|
||||
}) {
|
||||
const {id, route, isDuplicated} = props;
|
||||
return (
|
||||
<Panel
|
||||
grow={true}
|
||||
collapsable={false}
|
||||
floating={false}
|
||||
heading={'Route Info'}>
|
||||
<MockResponseDetails
|
||||
key={id}
|
||||
id={id}
|
||||
route={route}
|
||||
isDuplicated={isDuplicated}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export function ManageMockResponsePanel(props: Props) {
|
||||
const networkRouteManager = useContext(NetworkRouteContext);
|
||||
const [selectedId, setSelectedId] = useState<RequestId | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedId((selectedId) => {
|
||||
const keys = Object.keys(props.routes);
|
||||
let returnValue: string | null = null;
|
||||
// selectId is null when there are no rows or it is the first time rows are shown
|
||||
if (selectedId === null) {
|
||||
if (keys.length === 0) {
|
||||
// there are no rows
|
||||
returnValue = null;
|
||||
} else {
|
||||
// first time rows are shown
|
||||
returnValue = keys[0];
|
||||
}
|
||||
} else {
|
||||
if (keys.includes(selectedId)) {
|
||||
returnValue = selectedId;
|
||||
} else {
|
||||
// selectedId row value not in routes so default to first line
|
||||
returnValue = keys[0];
|
||||
}
|
||||
}
|
||||
return returnValue;
|
||||
});
|
||||
}, [props.routes]);
|
||||
const duplicatedIds = useMemo(() => _duplicateIds(props.routes), [
|
||||
props.routes,
|
||||
]);
|
||||
|
||||
function getSelectedIds(): Set<string> {
|
||||
const newSet = new Set<string>();
|
||||
newSet.add(selectedId ?? '');
|
||||
return newSet;
|
||||
}
|
||||
|
||||
function getPreviousId(id: string): string | null {
|
||||
const keys = Object.keys(props.routes);
|
||||
const currentIndex = keys.indexOf(id);
|
||||
if (currentIndex == 0) {
|
||||
return null;
|
||||
} else {
|
||||
return keys[currentIndex - 1];
|
||||
}
|
||||
}
|
||||
|
||||
function getNextId(id: string): string | null {
|
||||
const keys = Object.keys(props.routes);
|
||||
const currentIndex = keys.indexOf(id);
|
||||
if (currentIndex >= keys.length - 1) {
|
||||
return getPreviousId(id);
|
||||
} else {
|
||||
return keys[currentIndex + 1];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout.Container style={{height: 550}}>
|
||||
<Layout.Left>
|
||||
<Layout.Container width={450} pad={10} gap={5}>
|
||||
<Layout.Horizontal gap>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const newId = networkRouteManager.addRoute();
|
||||
setSelectedId(newId);
|
||||
}}>
|
||||
Add Route
|
||||
</Button>
|
||||
<NUX
|
||||
title="It is now possible to highlight calls from the network call list and convert them into mock routes."
|
||||
placement="bottom">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (
|
||||
!props.highlightedRows ||
|
||||
props.highlightedRows.size == 0
|
||||
) {
|
||||
message.info('No network calls have been highlighted');
|
||||
return;
|
||||
}
|
||||
networkRouteManager.copyHighlightedCalls(
|
||||
props.highlightedRows as Set<string>,
|
||||
props.requests,
|
||||
props.responses,
|
||||
);
|
||||
}}>
|
||||
Copy Highlighted Calls
|
||||
</Button>
|
||||
</NUX>
|
||||
</Layout.Horizontal>
|
||||
<Panel
|
||||
padded={false}
|
||||
grow={true}
|
||||
collapsable={false}
|
||||
floating={false}
|
||||
heading={'Routes'}>
|
||||
<ManagedTable
|
||||
hideHeader={true}
|
||||
multiline={false}
|
||||
columnSizes={ColumnSizes}
|
||||
columns={Columns}
|
||||
rows={_buildRows(
|
||||
props.routes,
|
||||
duplicatedIds,
|
||||
(id) => {
|
||||
Modal.confirm({
|
||||
title: 'Are you sure you want to delete this item?',
|
||||
icon: '',
|
||||
onOk() {
|
||||
const nextId = getNextId(id);
|
||||
networkRouteManager.removeRoute(id);
|
||||
setSelectedId(nextId);
|
||||
},
|
||||
onCancel() {},
|
||||
});
|
||||
},
|
||||
(id) => {
|
||||
networkRouteManager.enableRoute(id);
|
||||
},
|
||||
)}
|
||||
stickyBottom={true}
|
||||
autoHeight={false}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
onRowHighlighted={(selectedIds) => {
|
||||
const newSelectedId =
|
||||
selectedIds.length === 1 ? selectedIds[0] : null;
|
||||
setSelectedId(newSelectedId);
|
||||
}}
|
||||
highlightedRows={getSelectedIds()}
|
||||
/>
|
||||
</Panel>
|
||||
</Layout.Container>
|
||||
<Layout.Container>
|
||||
{selectedId && props.routes.hasOwnProperty(selectedId) && (
|
||||
<ManagedMockResponseRightPanel
|
||||
id={selectedId}
|
||||
route={props.routes[selectedId]}
|
||||
isDuplicated={duplicatedIds.includes(selectedId)}
|
||||
/>
|
||||
)}
|
||||
</Layout.Container>
|
||||
</Layout.Left>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
362
desktop/plugins/public/network/MockResponseDetails.tsx
Normal file
362
desktop/plugins/public/network/MockResponseDetails.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 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 {
|
||||
FlexRow,
|
||||
FlexColumn,
|
||||
Layout,
|
||||
Button,
|
||||
Input,
|
||||
Text,
|
||||
Tabs,
|
||||
Tab,
|
||||
Glyph,
|
||||
ManagedTable,
|
||||
Select,
|
||||
styled,
|
||||
colors,
|
||||
produce,
|
||||
} from 'flipper';
|
||||
import React, {useContext, useState} from 'react';
|
||||
import {NetworkRouteContext, NetworkRouteManager} from './index';
|
||||
import {RequestId, Route} from './types';
|
||||
|
||||
type Props = {
|
||||
id: RequestId;
|
||||
route: Route;
|
||||
isDuplicated: boolean;
|
||||
};
|
||||
|
||||
const StyledSelectContainer = styled(FlexRow)({
|
||||
paddingLeft: 6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 24,
|
||||
height: '100%',
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
const StyledSelect = styled(Select)({
|
||||
height: '100%',
|
||||
maxWidth: 400,
|
||||
});
|
||||
|
||||
const StyledText = styled(Text)({
|
||||
marginLeft: 6,
|
||||
marginTop: 8,
|
||||
});
|
||||
|
||||
const textAreaStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
marginTop: 8,
|
||||
height: 400,
|
||||
fontSize: 15,
|
||||
color: '#333',
|
||||
padding: 10,
|
||||
resize: 'none',
|
||||
fontFamily:
|
||||
'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
|
||||
display: 'inline-block',
|
||||
lineHeight: 1.5,
|
||||
border: '1px solid #dcdee2',
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#fff',
|
||||
cursor: 'text',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'break-word',
|
||||
};
|
||||
|
||||
const StyledInput = styled(Input)({
|
||||
width: '100%',
|
||||
height: 20,
|
||||
marginLeft: 8,
|
||||
flexGrow: 5,
|
||||
});
|
||||
|
||||
const HeaderStyledInput = styled(Input)({
|
||||
width: '100%',
|
||||
height: 20,
|
||||
marginTop: 6,
|
||||
marginBottom: 6,
|
||||
});
|
||||
|
||||
const HeaderGlyph = styled(Glyph)({
|
||||
marginTop: 6,
|
||||
marginBottom: 6,
|
||||
});
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'flex-start',
|
||||
alignContent: 'flex-start',
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const Warning = styled(FlexRow)({
|
||||
marginTop: 8,
|
||||
});
|
||||
|
||||
const HeadersColumnSizes = {
|
||||
close: '4%',
|
||||
warning: '4%',
|
||||
name: '35%',
|
||||
value: 'flex',
|
||||
};
|
||||
|
||||
const HeadersColumns = {
|
||||
close: {
|
||||
value: '',
|
||||
resizable: false,
|
||||
},
|
||||
warning: {
|
||||
value: '',
|
||||
resizable: false,
|
||||
},
|
||||
name: {
|
||||
value: 'Name',
|
||||
resizable: false,
|
||||
},
|
||||
value: {
|
||||
value: 'Value',
|
||||
resizable: false,
|
||||
},
|
||||
};
|
||||
|
||||
const selectedHighlight = {backgroundColor: colors.highlight};
|
||||
|
||||
function HeaderInput(props: {
|
||||
initialValue: string;
|
||||
isSelected: boolean;
|
||||
onUpdate: (newValue: string) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(props.initialValue);
|
||||
return (
|
||||
<HeaderStyledInput
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={value}
|
||||
style={props.isSelected ? selectedHighlight : undefined}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={() => props.onUpdate(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function _buildMockResponseHeaderRows(
|
||||
routeId: string,
|
||||
route: Route,
|
||||
selectedHeaderId: string | null,
|
||||
networkRouteManager: NetworkRouteManager,
|
||||
) {
|
||||
return Object.entries(route.responseHeaders).map(([id, header]) => {
|
||||
const selected = selectedHeaderId === id;
|
||||
return {
|
||||
columns: {
|
||||
name: {
|
||||
value: (
|
||||
<HeaderInput
|
||||
initialValue={header.key}
|
||||
isSelected={selected}
|
||||
onUpdate={(newValue: string) => {
|
||||
const newHeaders = produce(
|
||||
route.responseHeaders,
|
||||
(draftHeaders) => {
|
||||
draftHeaders[id].key = newValue;
|
||||
},
|
||||
);
|
||||
networkRouteManager.modifyRoute(routeId, {
|
||||
responseHeaders: newHeaders,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
value: {
|
||||
value: (
|
||||
<HeaderInput
|
||||
initialValue={header.value}
|
||||
isSelected={selected}
|
||||
onUpdate={(newValue: string) => {
|
||||
const newHeaders = produce(
|
||||
route.responseHeaders,
|
||||
(draftHeaders) => {
|
||||
draftHeaders[id].value = newValue;
|
||||
},
|
||||
);
|
||||
networkRouteManager.modifyRoute(routeId, {
|
||||
responseHeaders: newHeaders,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
close: {
|
||||
value: (
|
||||
<Layout.Container
|
||||
onClick={() => {
|
||||
const newHeaders = produce(
|
||||
route.responseHeaders,
|
||||
(draftHeaders) => {
|
||||
delete draftHeaders[id];
|
||||
},
|
||||
);
|
||||
networkRouteManager.modifyRoute(routeId, {
|
||||
responseHeaders: newHeaders,
|
||||
});
|
||||
}}>
|
||||
<HeaderGlyph name="cross-circle" color={colors.red} />
|
||||
</Layout.Container>
|
||||
),
|
||||
},
|
||||
},
|
||||
key: id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function MockResponseDetails({id, route, isDuplicated}: Props) {
|
||||
const networkRouteManager = useContext(NetworkRouteContext);
|
||||
const [activeTab, setActiveTab] = useState<string>('data');
|
||||
const [selectedHeaderIds, setSelectedHeaderIds] = useState<Array<RequestId>>(
|
||||
[],
|
||||
);
|
||||
const [nextHeaderId, setNextHeaderId] = useState(0);
|
||||
|
||||
const {requestUrl, requestMethod, responseData, responseStatus} = route;
|
||||
|
||||
let formattedResponse = '';
|
||||
try {
|
||||
formattedResponse = JSON.stringify(JSON.parse(responseData), null, 2);
|
||||
} catch (e) {
|
||||
formattedResponse = responseData;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<FlexRow style={{width: '100%'}}>
|
||||
<StyledSelectContainer>
|
||||
<StyledSelect
|
||||
grow={true}
|
||||
selected={requestMethod}
|
||||
options={{
|
||||
GET: 'GET',
|
||||
POST: 'POST',
|
||||
PATCH: 'PATCH',
|
||||
HEAD: 'HEAD',
|
||||
PUT: 'PUT',
|
||||
DELETE: 'DELETE',
|
||||
TRACE: 'TRACE',
|
||||
OPTIONS: 'OPTIONS',
|
||||
CONNECT: 'CONNECT',
|
||||
}}
|
||||
onChange={(text: string) =>
|
||||
networkRouteManager.modifyRoute(id, {requestMethod: text})
|
||||
}
|
||||
/>
|
||||
</StyledSelectContainer>
|
||||
<StyledInput
|
||||
type="text"
|
||||
placeholder="URL"
|
||||
value={requestUrl}
|
||||
onChange={(event) =>
|
||||
networkRouteManager.modifyRoute(id, {
|
||||
requestUrl: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FlexRow>
|
||||
<FlexRow style={{width: '20%'}}>
|
||||
<StyledInput
|
||||
type="text"
|
||||
placeholder="STATUS"
|
||||
value={responseStatus}
|
||||
onChange={(event) =>
|
||||
networkRouteManager.modifyRoute(id, {
|
||||
responseStatus: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FlexRow>
|
||||
{isDuplicated && (
|
||||
<Warning>
|
||||
<Glyph name="caution-triangle" color={colors.yellow} />
|
||||
<Text style={{marginLeft: 5}}>
|
||||
Route is duplicated (Same URL and Method)
|
||||
</Text>
|
||||
</Warning>
|
||||
)}
|
||||
<StyledText />
|
||||
<Tabs
|
||||
active={activeTab}
|
||||
onActive={(newActiveTab) => {
|
||||
if (newActiveTab != null) {
|
||||
setActiveTab(newActiveTab);
|
||||
}
|
||||
}}>
|
||||
<Tab key={'data'} label={'Data'}>
|
||||
<textarea
|
||||
style={textAreaStyle}
|
||||
wrap="soft"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={formattedResponse}
|
||||
onChange={(event) =>
|
||||
networkRouteManager.modifyRoute(id, {
|
||||
responseData: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key={'headers'} label={'Headers'}>
|
||||
<Layout.Container style={{width: '100%'}}>
|
||||
<Layout.Horizontal>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const newHeaders = {
|
||||
...route.responseHeaders,
|
||||
[nextHeaderId.toString()]: {key: '', value: ''},
|
||||
};
|
||||
setNextHeaderId(nextHeaderId + 1);
|
||||
networkRouteManager.modifyRoute(id, {
|
||||
responseHeaders: newHeaders,
|
||||
});
|
||||
}}
|
||||
compact
|
||||
padded
|
||||
style={{marginBottom: 10}}>
|
||||
Add Header
|
||||
</Button>
|
||||
</Layout.Horizontal>
|
||||
<Layout.ScrollContainer>
|
||||
<ManagedTable
|
||||
hideHeader={true}
|
||||
multiline={true}
|
||||
columnSizes={HeadersColumnSizes}
|
||||
columns={HeadersColumns}
|
||||
rows={_buildMockResponseHeaderRows(
|
||||
id,
|
||||
route,
|
||||
selectedHeaderIds.length === 1 ? selectedHeaderIds[0] : null,
|
||||
networkRouteManager,
|
||||
)}
|
||||
stickyBottom={true}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
onRowHighlighted={setSelectedHeaderIds}
|
||||
highlightedRows={new Set(selectedHeaderIds)}
|
||||
/>
|
||||
</Layout.ScrollContainer>
|
||||
</Layout.Container>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
83
desktop/plugins/public/network/MockResponseDialog.tsx
Normal file
83
desktop/plugins/public/network/MockResponseDialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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, styled, Layout, Spacer} from 'flipper';
|
||||
|
||||
import {ManageMockResponsePanel} from './ManageMockResponsePanel';
|
||||
import {Route, Request, Response} from './types';
|
||||
import React from 'react';
|
||||
|
||||
import {NetworkRouteContext} from './index';
|
||||
import {useContext} from 'react';
|
||||
|
||||
type Props = {
|
||||
routes: {[id: string]: Route};
|
||||
onHide: () => void;
|
||||
highlightedRows: Set<string> | null | undefined;
|
||||
requests: {[id: string]: Request};
|
||||
responses: {[id: string]: Response};
|
||||
};
|
||||
|
||||
const Title = styled('div')({
|
||||
fontWeight: 500,
|
||||
marginBottom: 10,
|
||||
marginTop: 8,
|
||||
});
|
||||
|
||||
const StyledContainer = styled(Layout.Container)({
|
||||
padding: 10,
|
||||
width: 1200,
|
||||
});
|
||||
|
||||
export function MockResponseDialog(props: Props) {
|
||||
const networkRouteManager = useContext(NetworkRouteContext);
|
||||
return (
|
||||
<StyledContainer pad gap width={1200}>
|
||||
<Title>Mock Network Responses</Title>
|
||||
<Layout.Container>
|
||||
<ManageMockResponsePanel
|
||||
routes={props.routes}
|
||||
highlightedRows={props.highlightedRows}
|
||||
requests={props.requests}
|
||||
responses={props.responses}
|
||||
/>
|
||||
</Layout.Container>
|
||||
<Layout.Horizontal gap>
|
||||
<Button
|
||||
compact
|
||||
padded
|
||||
onClick={() => {
|
||||
networkRouteManager.importRoutes();
|
||||
}}>
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
padded
|
||||
onClick={() => {
|
||||
networkRouteManager.exportRoutes();
|
||||
}}>
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
padded
|
||||
onClick={() => {
|
||||
networkRouteManager.clearRoutes();
|
||||
}}>
|
||||
Clear
|
||||
</Button>
|
||||
<Spacer />
|
||||
<Button compact padded onClick={props.onHide}>
|
||||
Close
|
||||
</Button>
|
||||
</Layout.Horizontal>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
889
desktop/plugins/public/network/RequestDetails.tsx
Normal file
889
desktop/plugins/public/network/RequestDetails.tsx
Normal file
@@ -0,0 +1,889 @@
|
||||
/**
|
||||
* 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,
|
||||
SmallText,
|
||||
} from 'flipper';
|
||||
import {decodeBody, getHeaderValue} from './utils';
|
||||
import {formatBytes, BodyOptions} 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;
|
||||
bodyFormat: string;
|
||||
onSelectFormat: (bodyFormat: string) => void;
|
||||
};
|
||||
export default class RequestDetails extends Component<RequestDetailsProps> {
|
||||
static Container = styled(FlexColumn)({
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
render() {
|
||||
const {request, response, bodyFormat, onSelectFormat} = this.props;
|
||||
const url = new URL(request.url);
|
||||
|
||||
const formattedText = bodyFormat == 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${
|
||||
response.isMock ? ' (Mocked)' : ''
|
||||
}`}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<HeaderInspector headers={response.headers} />
|
||||
</Panel>
|
||||
) : null}
|
||||
<Panel
|
||||
key={'responsebody'}
|
||||
heading={`Response Body${response.isMock ? ' (Mocked)' : ''}`}
|
||||
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={onSelectFormat}
|
||||
options={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 = [];
|
||||
Array.from(computedHeaders.entries())
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] == b[0] ? 0 : 1))
|
||||
.forEach(([key, value]) => {
|
||||
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;
|
||||
if (request.data == null || request.data.trim() === '') {
|
||||
return <Empty />;
|
||||
}
|
||||
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
|
||||
for (const formatter of bodyFormatters) {
|
||||
if (formatter.formatRequest) {
|
||||
try {
|
||||
const component = formatter.formatRequest(request);
|
||||
if (component) {
|
||||
return (
|
||||
<BodyContainer>
|
||||
{component}
|
||||
<FormattedBy>
|
||||
Formatted by {formatter.constructor.name}
|
||||
</FormattedBy>
|
||||
</BodyContainer>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'BodyFormatter exception from ' + formatter.constructor.name,
|
||||
e.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return renderRawBody(request);
|
||||
}
|
||||
}
|
||||
|
||||
class ResponseBodyInspector extends Component<{
|
||||
response: Response;
|
||||
request: Request;
|
||||
formattedText: boolean;
|
||||
}> {
|
||||
render() {
|
||||
const {request, response, formattedText} = this.props;
|
||||
if (response.data == null || response.data.trim() === '') {
|
||||
return <Empty />;
|
||||
}
|
||||
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
|
||||
for (const formatter of bodyFormatters) {
|
||||
if (formatter.formatResponse) {
|
||||
try {
|
||||
const component = formatter.formatResponse(request, response);
|
||||
if (component) {
|
||||
return (
|
||||
<BodyContainer>
|
||||
{component}
|
||||
<FormattedBy>
|
||||
Formatted by {formatter.constructor.name}
|
||||
</FormattedBy>
|
||||
</BodyContainer>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'BodyFormatter exception from ' + formatter.constructor.name,
|
||||
e.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return renderRawBody(response);
|
||||
}
|
||||
}
|
||||
|
||||
const FormattedBy = styled(SmallText)({
|
||||
marginTop: 8,
|
||||
fontSize: '0.7em',
|
||||
textAlign: 'center',
|
||||
display: 'block',
|
||||
});
|
||||
|
||||
const Empty = () => (
|
||||
<BodyContainer>
|
||||
<Text>(empty)</Text>
|
||||
</BodyContainer>
|
||||
);
|
||||
|
||||
function renderRawBody(container: Request | Response) {
|
||||
// TODO: we want decoding only for non-binary data! See D23403095
|
||||
const decoded = decodeBody(container);
|
||||
return (
|
||||
<BodyContainer>
|
||||
{decoded ? (
|
||||
<Text selectable wordWrap="break-word">
|
||||
{decoded}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<FormattedBy>(Failed to decode)</FormattedBy>
|
||||
<Text selectable wordWrap="break-word">
|
||||
{container.data}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</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/')) {
|
||||
if (response.data) {
|
||||
const src = `data:${getHeaderValue(
|
||||
response.headers,
|
||||
'content-type',
|
||||
)};base64,${response.data}`;
|
||||
return <ImageWithSize src={src} />;
|
||||
} else {
|
||||
// fallback to using the request url
|
||||
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, idx) => <JSONText key={idx}>{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 decoded = decodeBody(request);
|
||||
if (!decoded) {
|
||||
return undefined;
|
||||
}
|
||||
const data = querystring.parse(decoded);
|
||||
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')) {
|
||||
const decoded = decodeBody(request);
|
||||
if (!decoded) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
expandRoot={true}
|
||||
data={querystring.parse(decoded)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class BinaryFormatter {
|
||||
formatRequest(request: Request) {
|
||||
return this.format(request);
|
||||
}
|
||||
|
||||
formatResponse(_request: Request, response: Response) {
|
||||
return this.format(response);
|
||||
}
|
||||
|
||||
format(container: Request | Response) {
|
||||
if (
|
||||
getHeaderValue(container.headers, 'content-type') ===
|
||||
'application/octet-stream'
|
||||
) {
|
||||
return '(binary data)'; // we could offer a download button here?
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const BodyFormatters: Array<BodyFormatter> = [
|
||||
new ImageFormatter(),
|
||||
new VideoFormatter(),
|
||||
new LogEventFormatter(),
|
||||
new GraphQLBatchFormatter(),
|
||||
new GraphQLFormatter(),
|
||||
new JSONFormatter(),
|
||||
new FormUrlencodedFormatter(),
|
||||
new XMLTextFormatter(),
|
||||
new BinaryFormatter(),
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
175
desktop/plugins/public/network/__tests__/chunks.node.tsx
Normal file
175
desktop/plugins/public/network/__tests__/chunks.node.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 {combineBase64Chunks} from '../chunks';
|
||||
import {TestUtils, createState} from 'flipper-plugin';
|
||||
import * as NetworkPlugin from '../index';
|
||||
import {assembleChunksIfResponseIsComplete} from '../chunks';
|
||||
import path from 'path';
|
||||
import {PartialResponses, Response} from '../types';
|
||||
import {Base64} from 'js-base64';
|
||||
import * as fs from 'fs';
|
||||
import {promisify} from 'util';
|
||||
|
||||
const readFile = promisify(fs.readFile);
|
||||
|
||||
test('Test assembling base64 chunks', () => {
|
||||
const message = 'wassup john?';
|
||||
const chunks = message.match(/.{1,2}/g)?.map(btoa);
|
||||
|
||||
if (chunks === undefined) {
|
||||
throw new Error('invalid chunks');
|
||||
}
|
||||
|
||||
const output = combineBase64Chunks(chunks);
|
||||
expect(Base64.decode(output)).toBe('wassup john?');
|
||||
});
|
||||
|
||||
test('Reducer correctly adds initial chunk', () => {
|
||||
const {instance, sendEvent} = TestUtils.startPlugin(NetworkPlugin);
|
||||
expect(instance.partialResponses.get()).toEqual({});
|
||||
|
||||
sendEvent('partialResponse', {
|
||||
id: '1',
|
||||
timestamp: 123,
|
||||
status: 200,
|
||||
data: 'hello',
|
||||
reason: 'nothing',
|
||||
headers: [],
|
||||
isMock: false,
|
||||
insights: null,
|
||||
index: 0,
|
||||
totalChunks: 2,
|
||||
});
|
||||
|
||||
expect(instance.partialResponses.get()['1']).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"followupChunks": Object {},
|
||||
"initialResponse": Object {
|
||||
"data": "hello",
|
||||
"headers": Array [],
|
||||
"id": "1",
|
||||
"index": 0,
|
||||
"insights": null,
|
||||
"isMock": false,
|
||||
"reason": "nothing",
|
||||
"status": 200,
|
||||
"timestamp": 123,
|
||||
"totalChunks": 2,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('Reducer correctly adds followup chunk', () => {
|
||||
const {instance, sendEvent} = TestUtils.startPlugin(NetworkPlugin);
|
||||
expect(instance.partialResponses.get()).toEqual({});
|
||||
|
||||
sendEvent('partialResponse', {
|
||||
id: '1',
|
||||
totalChunks: 2,
|
||||
index: 1,
|
||||
data: 'hello',
|
||||
});
|
||||
expect(instance.partialResponses.get()['1']).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"followupChunks": Object {
|
||||
"1": "hello",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('Reducer correctly combines initial response and followup chunk', () => {
|
||||
const {instance, sendEvent} = TestUtils.startPlugin(NetworkPlugin);
|
||||
instance.partialResponses.set({
|
||||
'1': {
|
||||
followupChunks: {},
|
||||
initialResponse: {
|
||||
data: 'aGVs',
|
||||
headers: [],
|
||||
id: '1',
|
||||
insights: null,
|
||||
isMock: false,
|
||||
reason: 'nothing',
|
||||
status: 200,
|
||||
timestamp: 123,
|
||||
index: 0,
|
||||
totalChunks: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(instance.responses.get()).toEqual({});
|
||||
sendEvent('partialResponse', {
|
||||
id: '1',
|
||||
totalChunks: 2,
|
||||
index: 1,
|
||||
data: 'bG8=',
|
||||
});
|
||||
|
||||
expect(instance.partialResponses.get()).toEqual({});
|
||||
expect(instance.responses.get()['1']).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"data": "aGVsbG8=",
|
||||
"headers": Array [],
|
||||
"id": "1",
|
||||
"index": 0,
|
||||
"insights": null,
|
||||
"isMock": false,
|
||||
"reason": "nothing",
|
||||
"status": 200,
|
||||
"timestamp": 123,
|
||||
"totalChunks": 2,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
async function readJsonFixture(filename: string) {
|
||||
return JSON.parse(
|
||||
await readFile(path.join(__dirname, 'fixtures', filename), 'utf-8'),
|
||||
);
|
||||
}
|
||||
|
||||
test('handle small binary payloads correctly', async () => {
|
||||
const input = await readJsonFixture('partial_failing_example.json');
|
||||
const partials = createState<PartialResponses>({
|
||||
test: input,
|
||||
});
|
||||
const responses = createState<Record<string, Response>>({});
|
||||
expect(() => {
|
||||
// this used to throw
|
||||
assembleChunksIfResponseIsComplete(partials, responses, 'test');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test('handle non binary payloads correcty', async () => {
|
||||
const input = await readJsonFixture('partial_utf8_before.json');
|
||||
const partials = createState<PartialResponses>({
|
||||
test: input,
|
||||
});
|
||||
const responses = createState<Record<string, Response>>({});
|
||||
expect(() => {
|
||||
assembleChunksIfResponseIsComplete(partials, responses, 'test');
|
||||
}).not.toThrow();
|
||||
const expected = await readJsonFixture('partial_utf8_after.json');
|
||||
expect(responses.get()['test']).toEqual(expected);
|
||||
});
|
||||
|
||||
test('handle binary payloads correcty', async () => {
|
||||
const input = await readJsonFixture('partial_binary_before.json');
|
||||
const partials = createState<PartialResponses>({
|
||||
test: input,
|
||||
});
|
||||
const responses = createState<Record<string, Response>>({});
|
||||
expect(() => {
|
||||
assembleChunksIfResponseIsComplete(partials, responses, 'test');
|
||||
}).not.toThrow();
|
||||
const expected = await readJsonFixture('partial_binary_after.json');
|
||||
expect(responses.get()['test']).toEqual(expected);
|
||||
});
|
||||
101
desktop/plugins/public/network/__tests__/encoding.node.tsx
Normal file
101
desktop/plugins/public/network/__tests__/encoding.node.tsx
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 {readFile} from 'fs';
|
||||
import path from 'path';
|
||||
import {decodeBody} from '../utils';
|
||||
import {Response} from '../types';
|
||||
import {promisify} from 'util';
|
||||
import {readFileSync} from 'fs';
|
||||
|
||||
async function createMockResponse(input: string): Promise<Response> {
|
||||
const inputData = await promisify(readFile)(
|
||||
path.join(__dirname, 'fixtures', input),
|
||||
'ascii',
|
||||
);
|
||||
const gzip = input.includes('gzip'); // if gzip in filename, assume it is a gzipped body
|
||||
const testResponse: Response = {
|
||||
id: '0',
|
||||
timestamp: 0,
|
||||
status: 200,
|
||||
reason: 'dunno',
|
||||
headers: gzip
|
||||
? [
|
||||
{
|
||||
key: 'Content-Encoding',
|
||||
value: 'gzip',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
data: inputData.replace(/\s+?/g, '').trim(), // remove whitespace caused by copy past of the base64 data,
|
||||
isMock: false,
|
||||
insights: undefined,
|
||||
totalChunks: 1,
|
||||
index: 0,
|
||||
};
|
||||
return testResponse;
|
||||
}
|
||||
|
||||
describe('network data encoding', () => {
|
||||
const donatingExpected = readFileSync(
|
||||
path.join(__dirname, 'fixtures', 'donating.md'),
|
||||
'utf-8',
|
||||
).trim();
|
||||
const tinyLogoExpected = readFileSync(
|
||||
path.join(__dirname, 'fixtures', 'tiny_logo.png'),
|
||||
);
|
||||
const tinyLogoBase64Expected = readFileSync(
|
||||
path.join(__dirname, 'fixtures', 'tiny_logo.base64.txt'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
test('donating.md.utf8.ios.txt', async () => {
|
||||
const response = await createMockResponse('donating.md.utf8.ios.txt');
|
||||
expect(decodeBody(response).trim()).toEqual(donatingExpected);
|
||||
});
|
||||
|
||||
test('donating.md.utf8.gzip.ios.txt', async () => {
|
||||
const response = await createMockResponse('donating.md.utf8.gzip.ios.txt');
|
||||
expect(decodeBody(response).trim()).toEqual(donatingExpected);
|
||||
});
|
||||
|
||||
test('donating.md.utf8.android.txt', async () => {
|
||||
const response = await createMockResponse('donating.md.utf8.android.txt');
|
||||
expect(decodeBody(response).trim()).toEqual(donatingExpected);
|
||||
});
|
||||
|
||||
test('donating.md.utf8.gzip.android.txt', async () => {
|
||||
const response = await createMockResponse(
|
||||
'donating.md.utf8.gzip.android.txt',
|
||||
);
|
||||
expect(decodeBody(response).trim()).toEqual(donatingExpected);
|
||||
});
|
||||
|
||||
test('tiny_logo.android.txt', async () => {
|
||||
const response = await createMockResponse('tiny_logo.android.txt');
|
||||
expect(response.data).toEqual(tinyLogoExpected.toString('base64'));
|
||||
});
|
||||
|
||||
test('tiny_logo.android.txt - encoded', async () => {
|
||||
const response = await createMockResponse('tiny_logo.android.txt');
|
||||
// this compares to the correct base64 encoded src tag of the img in Flipper UI
|
||||
expect(response.data).toEqual(tinyLogoBase64Expected.trim());
|
||||
});
|
||||
|
||||
test('tiny_logo.ios.txt', async () => {
|
||||
const response = await createMockResponse('tiny_logo.ios.txt');
|
||||
expect(response.data).toEqual(tinyLogoExpected.toString('base64'));
|
||||
});
|
||||
|
||||
test('tiny_logo.ios.txt - encoded', async () => {
|
||||
const response = await createMockResponse('tiny_logo.ios.txt');
|
||||
// this compares to the correct base64 encoded src tag of the img in Flipper UI
|
||||
expect(response.data).toEqual(tinyLogoBase64Expected.trim());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
# 捐赠
|
||||
|
||||
MobX 是使您的项目成功的关键吗? 使用[捐赠按钮](https://mobxjs.github.io/mobx/donate.html)分享胜利!如果你留下一个名字,它将被添加到赞助商列表。
|
||||
@@ -0,0 +1 @@
|
||||
IyDmjZDotaAKCk1vYlgg5piv5L2/5oKo55qE6aG555uu5oiQ5Yqf55qE5YWz6ZSu5ZCX77yfIOS9 v+eUqFvmjZDotaDmjInpkq5dKGh0dHBzOi8vbW9ieGpzLmdpdGh1Yi5pby9tb2J4L2RvbmF0ZS5o dG1sKeWIhuS6q+iDnOWIqe+8geWmguaenOS9oOeVmeS4i+S4gOS4quWQjeWtl++8jOWug+Wwhuii q+a3u+WKoOWIsOi1nuWKqeWVhuWIl+ihqOOAggo=
|
||||
@@ -0,0 +1 @@
|
||||
H4sIAAAAAAAAAyWNXQvBUByH7/cpVm642e59B/dKLiwywpSjXDKsY6gl8hrjQkNGSeYtH8b5n51d +QoWl7+n5+kX4GnXYGeT4yKKFOXp6ECeL6pa7qThLa/u1KbYAH3hT2ievL4NxvDzWPC+5Pat2L+l nZbXs+NBGaFiKSyKeUWqZEtCOoPksiRklB8Qk0ohgVKCjPK5EGCN3HasPgO8+TxqsFbpfEaepjsY E6dNnCpxtmB0Ye+fdcCuw1Fjqx293EE3AR/ZeQ76BgYa4CFbWu+qyn0Bz88iqcgAAAA=
|
||||
@@ -0,0 +1 @@
|
||||
IyDmjZDotaAKCk1vYlgg5piv5L2/5oKo55qE6aG555uu5oiQ5Yqf55qE5YWz6ZSu5ZCX77yfIOS9v+eUqFvmjZDotaDmjInpkq5dKGh0dHBzOi8vbW9ieGpzLmdpdGh1Yi5pby9tb2J4L2RvbmF0ZS5odG1sKeWIhuS6q+iDnOWIqe+8geWmguaenOS9oOeVmeS4i+S4gOS4quWQjeWtl++8jOWug+Wwhuiiq+a3u+WKoOWIsOi1nuWKqeWVhuWIl+ihqOOAggo=
|
||||
@@ -0,0 +1 @@
|
||||
IyDmjZDotaAKCk1vYlgg5piv5L2/5oKo55qE6aG555uu5oiQ5Yqf55qE5YWz6ZSu5ZCX77yfIOS9v+eUqFvmjZDotaDmjInpkq5dKGh0dHBzOi8vbW9ieGpzLmdpdGh1Yi5pby9tb2J4L2RvbmF0ZS5odG1sKeWIhuS6q+iDnOWIqe+8geWmguaenOS9oOeVmeS4i+S4gOS4quWQjeWtl++8jOWug+Wwhuiiq+a3u+WKoOWIsOi1nuWKqeWVhuWIl+ihqOOAggo=
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user