Summary: Fix long standing issue where text by default in Flipper is not selectable. Which is super annoying when working with tables, trying to copy error messages, etc etc. Wanted to fix it with ant.design, but got so annoyed by not being able to select an error I needed, that I fixed it now :-P. This diff makes all text selectable by default, and then bails out for specific elements, like section headers, button captions, the left toolbar (navigation) and top toolbar to preserve the desktop app look & feel. Fixes a popular papercut issue Changelog: All text is now selectable by default in Flipper. Reviewed By: passy Differential Revision: D22897793 fbshipit-source-id: 7bc50a987e012595956c07d1997959a5480790aa
696 lines
16 KiB
TypeScript
696 lines
16 KiB
TypeScript
/**
|
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @format
|
|
*/
|
|
|
|
import {
|
|
TableBodyRow,
|
|
TableColumnOrder,
|
|
TableColumnSizes,
|
|
TableColumns,
|
|
TableRowSortOrder,
|
|
Props as PluginProps,
|
|
BaseAction,
|
|
DeviceLogEntry,
|
|
produce,
|
|
} from 'flipper';
|
|
import {Counter} from './LogWatcher';
|
|
|
|
import {
|
|
Text,
|
|
ManagedTableClass,
|
|
Button,
|
|
colors,
|
|
ContextMenu,
|
|
FlexColumn,
|
|
Glyph,
|
|
DetailSidebar,
|
|
FlipperDevicePlugin,
|
|
SearchableTable,
|
|
styled,
|
|
Device,
|
|
createPaste,
|
|
textContent,
|
|
KeyboardActions,
|
|
} from 'flipper';
|
|
import LogWatcher from './LogWatcher';
|
|
import React from 'react';
|
|
import {MenuTemplate} from 'app/src/ui/components/ContextMenu';
|
|
|
|
const LOG_WATCHER_LOCAL_STORAGE_KEY = 'LOG_WATCHER_LOCAL_STORAGE_KEY';
|
|
|
|
type Entries = ReadonlyArray<{
|
|
readonly row: TableBodyRow;
|
|
readonly entry: DeviceLogEntry;
|
|
}>;
|
|
|
|
type BaseState = {
|
|
readonly rows: ReadonlyArray<TableBodyRow>;
|
|
readonly entries: Entries;
|
|
readonly key2entry: {readonly [key: string]: DeviceLogEntry};
|
|
};
|
|
|
|
type AdditionalState = {
|
|
readonly highlightedRows: ReadonlySet<string>;
|
|
readonly counters: ReadonlyArray<Counter>;
|
|
readonly timeDirection: 'up' | 'down';
|
|
};
|
|
|
|
type State = BaseState & AdditionalState;
|
|
|
|
type PersistedState = {};
|
|
|
|
const Icon = styled(Glyph)({
|
|
marginTop: 5,
|
|
});
|
|
|
|
function getLineCount(str: string): number {
|
|
let count = 1;
|
|
if (!(typeof str === 'string')) {
|
|
return 0;
|
|
}
|
|
for (let i = 0; i < str.length; i++) {
|
|
if (str[i] === '\n') {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function keepKeys<A>(obj: A, keys: Array<string>): A {
|
|
const result: A = {} as A;
|
|
for (const key in obj) {
|
|
if (keys.includes(key)) {
|
|
result[key] = obj[key];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const COLUMN_SIZE = {
|
|
type: 40,
|
|
time: 120,
|
|
pid: 60,
|
|
tid: 60,
|
|
tag: 120,
|
|
app: 200,
|
|
message: 'flex',
|
|
} as const;
|
|
|
|
const COLUMNS = {
|
|
type: {
|
|
value: '',
|
|
},
|
|
time: {
|
|
value: 'Time',
|
|
sortable: true,
|
|
},
|
|
pid: {
|
|
value: 'PID',
|
|
},
|
|
tid: {
|
|
value: 'TID',
|
|
},
|
|
tag: {
|
|
value: 'Tag',
|
|
},
|
|
app: {
|
|
value: 'App',
|
|
},
|
|
message: {
|
|
value: 'Message',
|
|
},
|
|
} as const;
|
|
|
|
const INITIAL_COLUMN_ORDER = [
|
|
{
|
|
key: 'type',
|
|
visible: true,
|
|
},
|
|
{
|
|
key: 'time',
|
|
visible: true,
|
|
},
|
|
{
|
|
key: 'pid',
|
|
visible: false,
|
|
},
|
|
{
|
|
key: 'tid',
|
|
visible: false,
|
|
},
|
|
{
|
|
key: 'tag',
|
|
visible: true,
|
|
},
|
|
{
|
|
key: 'app',
|
|
visible: true,
|
|
},
|
|
{
|
|
key: 'message',
|
|
visible: true,
|
|
},
|
|
] as const;
|
|
|
|
const LOG_TYPES: {
|
|
[level: string]: {
|
|
label: string;
|
|
color: string;
|
|
icon?: React.ReactNode;
|
|
style?: Object;
|
|
};
|
|
} = {
|
|
verbose: {
|
|
label: 'Verbose',
|
|
color: colors.purple,
|
|
},
|
|
debug: {
|
|
label: 'Debug',
|
|
color: colors.grey,
|
|
},
|
|
info: {
|
|
label: 'Info',
|
|
icon: <Icon name="info-circle" color={colors.cyan} />,
|
|
color: colors.cyan,
|
|
},
|
|
warn: {
|
|
label: 'Warn',
|
|
style: {
|
|
backgroundColor: colors.yellowTint,
|
|
color: colors.yellow,
|
|
fontWeight: 500,
|
|
},
|
|
icon: <Icon name="caution-triangle" color={colors.yellow} />,
|
|
color: colors.yellow,
|
|
},
|
|
error: {
|
|
label: 'Error',
|
|
style: {
|
|
backgroundColor: colors.redTint,
|
|
color: colors.red,
|
|
fontWeight: 500,
|
|
},
|
|
icon: <Icon name="caution-octagon" color={colors.red} />,
|
|
color: colors.red,
|
|
},
|
|
fatal: {
|
|
label: 'Fatal',
|
|
style: {
|
|
backgroundColor: colors.redTint,
|
|
color: colors.red,
|
|
fontWeight: 700,
|
|
},
|
|
icon: <Icon name="stop" color={colors.red} />,
|
|
color: colors.red,
|
|
},
|
|
};
|
|
|
|
const DEFAULT_FILTERS = [
|
|
{
|
|
type: 'enum',
|
|
enum: Object.keys(LOG_TYPES).map((value) => ({
|
|
label: LOG_TYPES[value].label,
|
|
value,
|
|
})),
|
|
key: 'type',
|
|
value: [],
|
|
persistent: true,
|
|
},
|
|
];
|
|
|
|
const HiddenScrollText = styled(Text)({
|
|
alignSelf: 'baseline',
|
|
lineHeight: '130%',
|
|
marginTop: 5,
|
|
paddingBottom: 3,
|
|
'&::-webkit-scrollbar': {
|
|
display: 'none',
|
|
},
|
|
});
|
|
|
|
const LogCount = styled.div<{backgroundColor: string}>(({backgroundColor}) => ({
|
|
backgroundColor,
|
|
borderRadius: '999em',
|
|
fontSize: 11,
|
|
marginTop: 4,
|
|
minWidth: 16,
|
|
height: 16,
|
|
color: colors.white,
|
|
textAlign: 'center',
|
|
lineHeight: '16px',
|
|
paddingLeft: 4,
|
|
paddingRight: 4,
|
|
textOverflow: 'ellipsis',
|
|
overflow: 'hidden',
|
|
whiteSpace: 'nowrap',
|
|
}));
|
|
|
|
function pad(chunk: any, len: number): string {
|
|
let str = String(chunk);
|
|
while (str.length < len) {
|
|
str = `0${str}`;
|
|
}
|
|
return str;
|
|
}
|
|
|
|
export function addEntriesToState(
|
|
items: Entries,
|
|
state: BaseState = {
|
|
rows: [],
|
|
entries: [],
|
|
key2entry: {},
|
|
} as const,
|
|
addDirection: 'up' | 'down' = 'up',
|
|
): BaseState {
|
|
const rows = [...state.rows];
|
|
const entries = [...state.entries];
|
|
const key2entry = {...state.key2entry};
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
const {entry, row} = items[i];
|
|
entries.push({row, entry});
|
|
key2entry[row.key] = entry;
|
|
|
|
let previousEntry: DeviceLogEntry | null = null;
|
|
|
|
if (i > 0) {
|
|
previousEntry = items[i - 1].entry;
|
|
} else if (state.rows.length > 0 && state.entries.length > 0) {
|
|
previousEntry = state.entries[state.entries.length - 1].entry;
|
|
}
|
|
|
|
addRowIfNeeded(rows, row, entry, previousEntry, addDirection);
|
|
}
|
|
|
|
return {
|
|
entries,
|
|
rows,
|
|
key2entry,
|
|
};
|
|
}
|
|
|
|
export function addRowIfNeeded(
|
|
rows: Array<TableBodyRow>,
|
|
row: TableBodyRow,
|
|
entry: DeviceLogEntry,
|
|
previousEntry: DeviceLogEntry | null,
|
|
addDirection: 'up' | 'down' = 'up',
|
|
) {
|
|
const previousRow =
|
|
rows.length > 0
|
|
? addDirection === 'up'
|
|
? rows[rows.length - 1]
|
|
: rows[0]
|
|
: null;
|
|
if (
|
|
previousRow &&
|
|
previousEntry &&
|
|
entry.message === previousEntry.message &&
|
|
entry.tag === previousEntry.tag &&
|
|
previousRow.type != null
|
|
) {
|
|
// duplicate log, increase counter
|
|
const count =
|
|
previousRow.columns.type.value &&
|
|
previousRow.columns.type.value.props &&
|
|
typeof previousRow.columns.type.value.props.children === 'number'
|
|
? previousRow.columns.type.value.props.children + 1
|
|
: 2;
|
|
const type = LOG_TYPES[previousRow.type] || LOG_TYPES.debug;
|
|
previousRow.columns.type.value = (
|
|
<LogCount backgroundColor={type.color}>{count}</LogCount>
|
|
);
|
|
} else {
|
|
if (addDirection === 'up') {
|
|
rows.push(row);
|
|
} else {
|
|
rows.unshift(row);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function processEntry(
|
|
entry: DeviceLogEntry,
|
|
key: string,
|
|
): {
|
|
row: TableBodyRow;
|
|
entry: DeviceLogEntry;
|
|
} {
|
|
const {icon, style} = LOG_TYPES[entry.type] || LOG_TYPES.debug;
|
|
// build the item, it will either be batched or added straight away
|
|
return {
|
|
entry,
|
|
row: {
|
|
columns: {
|
|
type: {
|
|
value: icon,
|
|
align: 'center',
|
|
},
|
|
time: {
|
|
value: (
|
|
<HiddenScrollText code={true}>
|
|
{entry.date.toTimeString().split(' ')[0] +
|
|
'.' +
|
|
pad(entry.date.getMilliseconds(), 3)}
|
|
</HiddenScrollText>
|
|
),
|
|
},
|
|
message: {
|
|
value: (
|
|
<HiddenScrollText code={true}>{entry.message}</HiddenScrollText>
|
|
),
|
|
},
|
|
tag: {
|
|
value: <HiddenScrollText code={true}>{entry.tag}</HiddenScrollText>,
|
|
isFilterable: true,
|
|
},
|
|
pid: {
|
|
value: (
|
|
<HiddenScrollText code={true}>{String(entry.pid)}</HiddenScrollText>
|
|
),
|
|
isFilterable: true,
|
|
},
|
|
tid: {
|
|
value: (
|
|
<HiddenScrollText code={true}>{String(entry.tid)}</HiddenScrollText>
|
|
),
|
|
isFilterable: true,
|
|
},
|
|
app: {
|
|
value: <HiddenScrollText code={true}>{entry.app}</HiddenScrollText>,
|
|
isFilterable: true,
|
|
},
|
|
},
|
|
height: getLineCount(entry.message) * 15 + 10, // 15px per line height + 8px padding
|
|
style,
|
|
type: entry.type,
|
|
filterValue: entry.message,
|
|
key,
|
|
},
|
|
};
|
|
}
|
|
|
|
export default class LogTable extends FlipperDevicePlugin<
|
|
State,
|
|
BaseAction,
|
|
PersistedState
|
|
> {
|
|
static keyboardActions: KeyboardActions = [
|
|
'clear',
|
|
'goToBottom',
|
|
'createPaste',
|
|
];
|
|
|
|
batchTimer: NodeJS.Timeout | undefined;
|
|
|
|
static supportsDevice(device: Device) {
|
|
return (
|
|
device.os === 'Android' ||
|
|
device.os === 'Metro' ||
|
|
(device.os === 'iOS' && device.deviceType !== 'physical')
|
|
);
|
|
}
|
|
|
|
onKeyboardAction = (action: string) => {
|
|
if (action === 'clear') {
|
|
this.clearLogs();
|
|
} else if (action === 'goToBottom') {
|
|
this.goToBottom();
|
|
} else if (action === 'createPaste') {
|
|
this.createPaste();
|
|
}
|
|
};
|
|
|
|
restoreSavedCounters = (): Array<Counter> => {
|
|
const savedCounters =
|
|
window.localStorage.getItem(LOG_WATCHER_LOCAL_STORAGE_KEY) || '[]';
|
|
return JSON.parse(savedCounters).map((counter: Counter) => ({
|
|
...counter,
|
|
expression: new RegExp(counter.label, 'gi'),
|
|
count: 0,
|
|
}));
|
|
};
|
|
|
|
calculateHighlightedRows = (
|
|
deepLinkPayload: unknown,
|
|
rows: ReadonlyArray<TableBodyRow>,
|
|
): Set<string> => {
|
|
const highlightedRows = new Set<string>();
|
|
if (typeof deepLinkPayload !== 'string') {
|
|
return highlightedRows;
|
|
}
|
|
|
|
// Run through array from last to first, because we want to show the last
|
|
// time it the log we are looking for appeared.
|
|
for (let i = rows.length - 1; i >= 0; i--) {
|
|
const filterValue = rows[i].filterValue;
|
|
if (filterValue != null && filterValue.includes(deepLinkPayload)) {
|
|
highlightedRows.add(rows[i].key);
|
|
break;
|
|
}
|
|
}
|
|
if (highlightedRows.size <= 0) {
|
|
// Check if the individual lines in the deeplinkPayload is matched or not.
|
|
const arr = deepLinkPayload.split('\n');
|
|
for (const msg of arr) {
|
|
for (let i = rows.length - 1; i >= 0; i--) {
|
|
const filterValue = rows[i].filterValue;
|
|
if (filterValue != null && filterValue.includes(msg)) {
|
|
highlightedRows.add(rows[i].key);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return highlightedRows;
|
|
};
|
|
|
|
tableRef: ManagedTableClass | undefined;
|
|
columns: TableColumns;
|
|
columnSizes: TableColumnSizes;
|
|
columnOrder: TableColumnOrder;
|
|
logListener: Symbol | undefined;
|
|
|
|
batch: Array<{
|
|
readonly row: TableBodyRow;
|
|
readonly entry: DeviceLogEntry;
|
|
}> = [];
|
|
queued: boolean = false;
|
|
counter: number = 0;
|
|
|
|
constructor(props: PluginProps<PersistedState>) {
|
|
super(props);
|
|
const supportedColumns = this.device.supportedColumns();
|
|
this.columns = keepKeys(COLUMNS, supportedColumns);
|
|
this.columnSizes = keepKeys(COLUMN_SIZE, supportedColumns);
|
|
this.columnOrder = INITIAL_COLUMN_ORDER.filter((obj) =>
|
|
supportedColumns.includes(obj.key),
|
|
);
|
|
|
|
const initialState = addEntriesToState(
|
|
this.device
|
|
.getLogs()
|
|
.map((log) => processEntry(log, String(this.counter++))),
|
|
this.state,
|
|
);
|
|
this.state = {
|
|
...initialState,
|
|
highlightedRows: this.calculateHighlightedRows(
|
|
props.deepLinkPayload,
|
|
initialState.rows,
|
|
),
|
|
counters: this.restoreSavedCounters(),
|
|
timeDirection: 'up',
|
|
};
|
|
|
|
this.logListener = this.device.addLogListener((entry: DeviceLogEntry) => {
|
|
const processedEntry = processEntry(entry, String(this.counter++));
|
|
this.incrementCounterIfNeeded(processedEntry.entry);
|
|
this.scheduleEntryForBatch(processedEntry);
|
|
});
|
|
}
|
|
|
|
incrementCounterIfNeeded = (entry: DeviceLogEntry) => {
|
|
let counterUpdated = false;
|
|
const counters = this.state.counters.map((counter) => {
|
|
if (entry.message.match(counter.expression)) {
|
|
counterUpdated = true;
|
|
if (counter.notify) {
|
|
new Notification(`${counter.label}`, {
|
|
body: 'The watched log message appeared',
|
|
});
|
|
}
|
|
return {
|
|
...counter,
|
|
count: counter.count + 1,
|
|
};
|
|
} else {
|
|
return counter;
|
|
}
|
|
});
|
|
if (counterUpdated) {
|
|
this.setState({counters});
|
|
}
|
|
};
|
|
|
|
scheduleEntryForBatch = (item: {
|
|
row: TableBodyRow;
|
|
entry: DeviceLogEntry;
|
|
}) => {
|
|
// batch up logs to be processed every 250ms, if we have lots of log
|
|
// messages coming in, then calling an setState 200+ times is actually
|
|
// pretty expensive
|
|
this.batch.push(item);
|
|
|
|
if (!this.queued) {
|
|
this.queued = true;
|
|
|
|
this.batchTimer = setTimeout(() => {
|
|
const thisBatch = this.batch;
|
|
this.batch = [];
|
|
this.queued = false;
|
|
this.setState((state) =>
|
|
addEntriesToState(thisBatch, state, state.timeDirection),
|
|
);
|
|
}, 100);
|
|
}
|
|
};
|
|
|
|
componentWillUnmount() {
|
|
if (this.batchTimer) {
|
|
clearTimeout(this.batchTimer);
|
|
}
|
|
|
|
if (this.logListener) {
|
|
this.device.removeLogListener(this.logListener);
|
|
}
|
|
}
|
|
|
|
clearLogs = () => {
|
|
this.device.clearLogs().catch((e) => {
|
|
console.error('Failed to clear logs: ', e);
|
|
});
|
|
this.setState({
|
|
entries: [],
|
|
rows: [],
|
|
highlightedRows: new Set(),
|
|
key2entry: {},
|
|
counters: this.state.counters.map((counter) => ({
|
|
...counter,
|
|
count: 0,
|
|
})),
|
|
});
|
|
};
|
|
|
|
createPaste = () => {
|
|
let paste = '';
|
|
const mapFn = (row: TableBodyRow) =>
|
|
Object.keys(COLUMNS)
|
|
.map((key) => textContent(row.columns[key].value))
|
|
.join('\t');
|
|
|
|
if (this.state.highlightedRows.size > 0) {
|
|
// create paste from selection
|
|
paste = this.state.rows
|
|
.filter((row) => this.state.highlightedRows.has(row.key))
|
|
.map(mapFn)
|
|
.join('\n');
|
|
} else {
|
|
// create paste with all rows
|
|
paste = this.state.rows.map(mapFn).join('\n');
|
|
}
|
|
createPaste(paste);
|
|
};
|
|
|
|
setTableRef = (ref: ManagedTableClass) => {
|
|
this.tableRef = ref;
|
|
};
|
|
|
|
goToBottom = () => {
|
|
if (this.tableRef != null) {
|
|
this.tableRef.scrollToBottom();
|
|
}
|
|
};
|
|
|
|
onRowHighlighted = (highlightedRows: Array<string>) => {
|
|
this.setState({
|
|
...this.state,
|
|
highlightedRows: new Set(highlightedRows),
|
|
});
|
|
};
|
|
|
|
renderSidebar = () => {
|
|
return (
|
|
<LogWatcher
|
|
counters={this.state.counters}
|
|
onChange={(counters) =>
|
|
this.setState({counters}, () =>
|
|
window.localStorage.setItem(
|
|
LOG_WATCHER_LOCAL_STORAGE_KEY,
|
|
JSON.stringify(this.state.counters),
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
);
|
|
};
|
|
|
|
static ContextMenu = styled(ContextMenu)({
|
|
flex: 1,
|
|
});
|
|
|
|
buildContextMenuItems: () => MenuTemplate = () => [
|
|
{
|
|
type: 'separator',
|
|
},
|
|
{
|
|
label: 'Clear all',
|
|
click: this.clearLogs,
|
|
},
|
|
];
|
|
|
|
render() {
|
|
return (
|
|
<LogTable.ContextMenu
|
|
buildItems={this.buildContextMenuItems}
|
|
component={FlexColumn}>
|
|
<SearchableTable
|
|
innerRef={this.setTableRef}
|
|
floating={false}
|
|
multiline={true}
|
|
columnSizes={this.columnSizes}
|
|
columnOrder={this.columnOrder}
|
|
columns={this.columns}
|
|
rows={this.state.rows}
|
|
highlightedRows={this.state.highlightedRows}
|
|
onRowHighlighted={this.onRowHighlighted}
|
|
multiHighlight={true}
|
|
defaultFilters={DEFAULT_FILTERS}
|
|
zebra={false}
|
|
actions={<Button onClick={this.clearLogs}>Clear Logs</Button>}
|
|
allowRegexSearch={true}
|
|
// If the logs is opened through deeplink, then don't scroll as the row is highlighted
|
|
stickyBottom={
|
|
!(this.props.deepLinkPayload && this.state.highlightedRows.size > 0)
|
|
}
|
|
initialSortOrder={{key: 'time', direction: 'up'}}
|
|
onSort={(order: TableRowSortOrder) =>
|
|
this.setState(
|
|
produce((prevState) => {
|
|
prevState.rows.reverse();
|
|
prevState.timeDirection = order.direction;
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
<DetailSidebar>{this.renderSidebar()}</DetailSidebar>
|
|
</LogTable.ContextMenu>
|
|
);
|
|
}
|
|
}
|