feat(demo): support multi-select in logcat and packet log

related to #425
This commit is contained in:
Simon Chan 2023-01-27 15:35:39 +08:00
parent 055da71f6c
commit 52abdd146b
No known key found for this signature in database
GPG key ID: A8B69F750B9BCEDD
8 changed files with 1075 additions and 835 deletions

View file

@ -33,7 +33,7 @@
"@yume-chan/struct": "workspace:^0.0.18",
"mobx": "^6.7.0",
"mobx-react-lite": "^3.4.0",
"next": "13.1.1",
"next": "13.1.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"webm-muxer": "^1.1.0",
@ -46,9 +46,9 @@
"@mdx-js/loader": "^2.2.1",
"@mdx-js/react": "^2.2.1",
"@next/mdx": "^13.1.1",
"@types/react": "18.0.26",
"@types/react": "18.0.27",
"eslint": "^8.31.0",
"eslint-config-next": "13.1.1",
"eslint-config-next": "13.1.5",
"source-map-loader": "^4.0.1",
"typescript": "^4.9.4"
}

View file

@ -4,23 +4,22 @@ import { withDisplayName } from "../utils";
const useClasses = makeStyles({
root: {
width: '100%',
height: '100%',
overflowY: 'auto',
width: "100%",
height: "100%",
overflowY: "auto",
},
flex: {
display: 'flex',
display: "flex",
},
cell: {
fontFamily: '"Cascadia Code", Consolas, monospace',
},
lineNumber: {
textAlign: 'right',
textAlign: "right",
},
hex: {
marginLeft: '40px',
fontVariantLigatures: 'none',
marginLeft: "40px",
fontVariantLigatures: "none",
},
});
@ -32,20 +31,19 @@ const PRINTABLE_CHARACTERS: [number, number][] = [
export function isPrintableCharacter(code: number) {
return PRINTABLE_CHARACTERS.some(
([start, end]) =>
code >= start &&
code <= end
([start, end]) => code >= start && code <= end
);
}
export function toCharacter(code: number) {
if (isPrintableCharacter(code))
if (isPrintableCharacter(code)) {
return String.fromCharCode(code);
return '.';
}
return ".";
}
export function toText(data: Uint8Array) {
let result = '';
let result = "";
for (const code of data) {
result += toCharacter(code);
}
@ -59,10 +57,8 @@ export interface HexViewerProps {
data: Uint8Array;
}
export const HexViewer = withDisplayName('HexViewer')(({
className,
data
}: HexViewerProps) => {
export const HexViewer = withDisplayName("HexViewer")(
({ className, data }: HexViewerProps) => {
const classes = useClasses();
// Because ADB packets are usually small,
@ -73,26 +69,16 @@ export const HexViewer = withDisplayName('HexViewer')(({
const hexRows: ReactNode[] = [];
const textRows: ReactNode[] = [];
for (let i = 0; i < data.length; i += PER_ROW) {
lineNumbers.push(
<div>
{i.toString(16)}
</div>
);
lineNumbers.push(<div key={i}>{i.toString(16)}</div>);
let hex = '';
let hex = "";
for (let j = i; j < i + PER_ROW && j < data.length; j++) {
hex += data[j].toString(16).padStart(2, '0') + ' ';
hex += data[j].toString(16).padStart(2, "0") + " ";
}
hexRows.push(
<div>
{hex}
</div>
);
hexRows.push(<div key={i}>{hex}</div>);
textRows.push(
<div>
{toText(data.slice(i, i + PER_ROW))}
</div>
<div key={i}>{toText(data.slice(i, i + PER_ROW))}</div>
);
}
@ -106,7 +92,12 @@ export const HexViewer = withDisplayName('HexViewer')(({
return (
<div className={mergeClasses(classes.root, className)}>
<div className={classes.flex}>
<div className={mergeClasses(classes.cell, classes.lineNumber)}>
<div
className={mergeClasses(
classes.cell,
classes.lineNumber
)}
>
{children.lineNumbers}
</div>
<div className={mergeClasses(classes.cell, classes.hex)}>
@ -118,4 +109,5 @@ export const HexViewer = withDisplayName('HexViewer')(({
</div>
</div>
);
});
}
);

View file

@ -1,9 +1,4 @@
import {
Checkbox,
ICommandBarItemProps,
Stack,
StackItem,
} from "@fluentui/react";
import { ICommandBarItemProps, Stack, StackItem } from "@fluentui/react";
import { makeStyles, mergeClasses, shorthands } from "@griffel/react";
import {
AndroidLogEntry,
@ -12,7 +7,6 @@ import {
LogcatFormat,
formatAndroidLogEntry,
} from "@yume-chan/android-bin";
import { BTree } from "@yume-chan/b-tree";
import {
AbortController,
ReadableStream,
@ -28,7 +22,7 @@ import {
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import { FormEvent, useEffect, useState } from "react";
import { PointerEvent } from "react";
import {
CommandBar,
@ -39,6 +33,7 @@ import {
} from "../components";
import { GLOBAL_STATE } from "../state";
import { Icons, RouteStackProps, useStableCallback } from "../utils";
import { ObservableListSelection } from "./packet-log";
const LINE_HEIGHT = 32;
@ -85,11 +80,10 @@ const state = makeAutoObservable(
buffer: [] as LogRow[],
flushRequested: false,
list: [] as LogRow[],
selection: new BTree(6),
selection: new ObservableListSelection(),
count: 0,
stream: undefined as ReadableStream<AndroidLogEntry> | undefined,
stopSignal: undefined as AbortController | undefined,
selectedCount: 0,
animationFrameId: undefined as number | undefined,
start() {
if (this.running) {
@ -131,7 +125,6 @@ const state = makeAutoObservable(
clear() {
this.list = [];
this.selection.clear();
this.selectedCount = 0;
},
get empty() {
return this.list.length === 0;
@ -162,7 +155,7 @@ const state = makeAutoObservable(
{
key: "copyAll",
text: "Copy Rows",
disabled: this.selectedCount === 0,
disabled: this.selection.size === 0,
iconProps: { iconName: Icons.Copy },
onClick: () => {
let text = "";
@ -181,7 +174,7 @@ const state = makeAutoObservable(
{
key: "copyText",
text: "Copy Messages",
disabled: this.selectedCount === 0,
disabled: this.selection.size === 0,
iconProps: { iconName: Icons.Copy },
onClick: () => {
let text = "";
@ -197,54 +190,6 @@ const state = makeAutoObservable(
},
get columns(): Column[] {
return [
{
width: 40,
title: "",
CellComponent: ({
rowIndex,
columnIndex,
className,
...rest
}) => {
const [checked, setChecked] = useState(false);
useEffect(() => {
setChecked(this.selection.has(rowIndex));
}, [rowIndex]);
const handleChange = useStableCallback(
(e?: FormEvent<EventTarget>, checked?: boolean) => {
if (checked === undefined) {
return;
}
if (checked) {
this.selection.add(rowIndex);
setChecked(true);
} else {
this.selection.delete(rowIndex);
setChecked(false);
}
runInAction(() => {
// Trigger mobx
this.selectedCount = this.selection.size;
});
}
);
return (
<Stack
className={className}
verticalAlign="center"
horizontalAlign="center"
{...rest}
>
<Checkbox
checked={checked}
onChange={handleChange}
/>
</Stack>
);
},
},
{
width: 200,
title: "Time",
@ -445,22 +390,35 @@ const Header = observer(function Header({
);
});
const Row = function Row({ className, rowIndex, ...rest }: GridRowProps) {
const item = state.list[rowIndex];
const Row = observer(function Row({
className,
rowIndex,
...rest
}: GridRowProps) {
const classes = useClasses();
const handleClick = useStableCallback(() => {
runInAction(() => {});
const handlePointerDown = useStableCallback(
(e: PointerEvent<HTMLDivElement>) => {
runInAction(() => {
e.preventDefault();
e.stopPropagation();
state.selection.select(rowIndex, e.ctrlKey, e.shiftKey);
});
}
);
return (
<div
className={mergeClasses(className, classes.row)}
onClick={handleClick}
className={mergeClasses(
className,
classes.row,
state.selection.has(rowIndex) && classes.selected
)}
onPointerDown={handlePointerDown}
{...rest}
/>
);
};
});
const LogcatPage: NextPage = () => {
const classes = useClasses();

View file

@ -1,10 +1,22 @@
import { ICommandBarItemProps, Stack, StackItem } from "@fluentui/react";
import { makeStyles, mergeClasses, shorthands } from "@griffel/react";
import { AdbCommand, decodeUtf8 } from "@yume-chan/adb";
import { autorun, makeAutoObservable, observable, runInAction } from "mobx";
import { BTree, BTreeNode } from "@yume-chan/b-tree";
import {
IAtom,
IObservableValue,
action,
autorun,
createAtom,
makeAutoObservable,
observable,
onBecomeUnobserved,
runInAction,
} from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import { PointerEvent } from "react";
import {
CommandBar,
Grid,
@ -15,7 +27,7 @@ import {
HexViewer,
toText,
} from "../components";
import { GLOBAL_STATE, PacketLogItem } from "../state";
import { GLOBAL_STATE } from "../state";
import {
Icons,
RouteStackProps,
@ -23,6 +35,139 @@ import {
withDisplayName,
} from "../utils";
export class ObservableBTree implements BTree {
data: BTree;
hasMap: Map<number, IObservableValue<boolean>>;
keys: IAtom;
constructor(order: number) {
this.data = new BTree(order);
this.hasMap = new Map();
this.keys = createAtom("ObservableBTree.keys");
}
get order(): number {
return this.data.order;
}
get root(): BTreeNode {
return this.data.root;
}
get size(): number {
this.keys.reportObserved();
return this.data.size;
}
has(value: number): boolean {
if (!this.hasMap.has(value)) {
const observableHasValue = observable.box(this.data.has(value));
onBecomeUnobserved(observableHasValue, () =>
this.hasMap.delete(value)
);
this.hasMap.set(value, observableHasValue);
}
return this.hasMap.get(value)!.get();
}
add(value: number): boolean {
if (this.data.add(value)) {
this.hasMap.get(value)?.set(true);
this.keys.reportChanged();
return true;
}
return false;
}
delete(value: number): boolean {
if (this.data.delete(value)) {
this.hasMap.get(value)?.set(false);
this.keys.reportChanged();
return true;
}
return false;
}
clear(): void {
if (this.data.size === 0) {
return;
}
this.data.clear();
for (const entry of this.hasMap) {
entry[1].set(false);
}
this.keys.reportChanged();
}
[Symbol.iterator](): Generator<number, void, void> {
this.keys.reportObserved();
return this.data[Symbol.iterator]();
}
}
export class ObservableListSelection {
selected = new ObservableBTree(6);
rangeStart = 0;
selectedIndex: number | null = null;
constructor() {
makeAutoObservable(this);
}
get size() {
return this.selected.size;
}
has(index: number) {
return this.selected.has(index);
}
select(index: number, ctrlKey: boolean, shiftKey: boolean) {
if (this.rangeStart !== null && shiftKey) {
if (!ctrlKey) {
this.selected.clear();
}
let [start, end] = [this.rangeStart, index];
if (start > end) {
[start, end] = [end, start];
}
for (let i = start; i <= end; i += 1) {
this.selected.add(i);
}
this.selectedIndex = index;
return;
}
if (ctrlKey) {
if (this.selected.has(index)) {
this.selected.delete(index);
this.selectedIndex = null;
} else {
this.selected.add(index);
this.selectedIndex = index;
}
this.rangeStart = index;
return;
}
this.selected.clear();
this.selected.add(index);
this.rangeStart = index;
this.selectedIndex = index;
}
clear() {
this.selected.clear();
this.rangeStart = 0;
this.selectedIndex = null;
}
[Symbol.iterator]() {
return this.selected[Symbol.iterator]();
}
}
const ADB_COMMAND_NAME = {
[AdbCommand.Auth]: "AUTH",
[AdbCommand.Close]: "CLSE",
@ -38,6 +183,12 @@ interface Column extends GridColumn {
const LINE_HEIGHT = 32;
function uint8ArrayToHexString(array: Uint8Array) {
return Array.from(array)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join(" ");
}
const state = new (class {
get empty() {
return !GLOBAL_STATE.logs.length;
@ -50,21 +201,60 @@ const state = new (class {
disabled: this.empty,
iconProps: { iconName: Icons.Delete },
text: "Clear",
onClick: () => GLOBAL_STATE.clearLog(),
onClick: action(() => GLOBAL_STATE.clearLog()),
},
{
key: "select-all",
disabled: this.empty,
iconProps: { iconName: Icons.Wand },
text: "Select All",
onClick: action(() => {
this.selection.clear();
this.selection.select(
GLOBAL_STATE.logs.length - 1,
false,
true
);
}),
},
{
key: "copy",
disabled: this.selection.size === 0,
iconProps: { iconName: Icons.Copy },
text: "Copy",
onClick: () => {
let text = "";
for (const index of this.selection) {
const entry = GLOBAL_STATE.logs[index];
// prettier-ignore
text += `${
entry.timestamp!.toISOString()
}\t${
entry.direction === 'in' ? "IN" : "OUT"
}\t${
ADB_COMMAND_NAME[entry.command as keyof typeof ADB_COMMAND_NAME]
}\t${
entry.arg0.toString(16).padStart(8,'0')
}\t${
entry.arg1.toString(16).padStart(8,'0')
}\t${
uint8ArrayToHexString(entry.payload)
}\n`;
}
navigator.clipboard.writeText(text);
},
},
];
}
selectedPacket: PacketLogItem | undefined = undefined;
selection = new ObservableListSelection();
constructor() {
makeAutoObservable(this, {
selectedPacket: observable.ref,
});
makeAutoObservable(this, {});
autorun(() => {
if (GLOBAL_STATE.logs.length === 0) {
this.selectedPacket = undefined;
runInAction(() => this.selection.clear());
}
});
}
@ -244,21 +434,24 @@ const Row = observer(function Row({
}: GridRowProps) {
const classes = useClasses();
const handleClick = useStableCallback(() => {
const handlePointerDown = useStableCallback(
(e: PointerEvent<HTMLDivElement>) => {
runInAction(() => {
state.selectedPacket = GLOBAL_STATE.logs[rowIndex];
});
e.preventDefault();
e.stopPropagation();
state.selection.select(rowIndex, e.ctrlKey, e.shiftKey);
});
}
);
return (
<div
className={mergeClasses(
className,
classes.row,
state.selectedPacket === GLOBAL_STATE.logs[rowIndex] &&
classes.selected
state.selection.has(rowIndex) && classes.selected
)}
onClick={handleClick}
onPointerDown={handlePointerDown}
{...rest}
/>
);
@ -286,12 +479,16 @@ const PacketLog: NextPage = () => {
/>
</StackItem>
{state.selectedPacket &&
state.selectedPacket.payload.length > 0 && (
{state.selection.selectedIndex !== null &&
GLOBAL_STATE.logs[state.selection.selectedIndex].payload
.length > 0 && (
<StackItem className={classes.grow} grow>
<HexViewer
className={classes.hexViewer}
data={state.selectedPacket.payload}
data={
GLOBAL_STATE.logs[state.selection.selectedIndex]
.payload
}
/>
</StackItem>
)}

View file

@ -6,6 +6,7 @@ export type PacketLogItemDirection = "in" | "out";
export interface PacketLogItem extends AdbPacketData {
direction: PacketLogItemDirection;
timestamp?: Date;
commandString?: string;
arg0String?: string;
arg1String?: string;
@ -48,11 +49,12 @@ export class GlobalState {
appendLog(direction: PacketLogItemDirection, packet: AdbPacketData) {
(packet as PacketLogItem).direction = direction;
(packet as PacketLogItem).timestamp = new Date();
this.logs.push(packet as PacketLogItem);
}
clearLog() {
this.logs = [];
this.logs.length = 0;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush.
{
"pnpmShrinkwrapHash": "cc0a83ec8632b8d767ce54866717a4770246d3dd",
"pnpmShrinkwrapHash": "a4548759bf683ee48a45a7367030c615f7b33683",
"preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f"
}

View file

@ -1,4 +1,5 @@
// cspell: ignore logcat
// cspell: ignore usec
import { AdbCommandBase, AdbSubprocessNoneProtocol } from "@yume-chan/adb";
import {
@ -124,12 +125,21 @@ export function formatAndroidLogEntry(
switch (format) {
// TODO: implement other formats
default:
return `${
default: {
// prettier-ignore
const text=`${
AndroidLogPriorityToCharacter[entry.priority]
}/${entry.tag.padEnd(8)}(${uid}${entry.pid
.toString()
.padStart(5)}): ${entry.message}`;
}/${
entry.tag.padEnd(8)
}(${
uid
}${
entry.pid.toString().padStart(5)
}): ${
entry.message
}`;
return text;
}
}
}