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

View file

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

View file

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

View file

@ -1,10 +1,22 @@
import { ICommandBarItemProps, Stack, StackItem } from "@fluentui/react"; import { ICommandBarItemProps, Stack, StackItem } from "@fluentui/react";
import { makeStyles, mergeClasses, shorthands } from "@griffel/react"; import { makeStyles, mergeClasses, shorthands } from "@griffel/react";
import { AdbCommand, decodeUtf8 } from "@yume-chan/adb"; 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 { observer } from "mobx-react-lite";
import { NextPage } from "next"; import { NextPage } from "next";
import Head from "next/head"; import Head from "next/head";
import { PointerEvent } from "react";
import { import {
CommandBar, CommandBar,
Grid, Grid,
@ -15,7 +27,7 @@ import {
HexViewer, HexViewer,
toText, toText,
} from "../components"; } from "../components";
import { GLOBAL_STATE, PacketLogItem } from "../state"; import { GLOBAL_STATE } from "../state";
import { import {
Icons, Icons,
RouteStackProps, RouteStackProps,
@ -23,6 +35,139 @@ import {
withDisplayName, withDisplayName,
} from "../utils"; } 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 = { const ADB_COMMAND_NAME = {
[AdbCommand.Auth]: "AUTH", [AdbCommand.Auth]: "AUTH",
[AdbCommand.Close]: "CLSE", [AdbCommand.Close]: "CLSE",
@ -38,6 +183,12 @@ interface Column extends GridColumn {
const LINE_HEIGHT = 32; 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 { const state = new (class {
get empty() { get empty() {
return !GLOBAL_STATE.logs.length; return !GLOBAL_STATE.logs.length;
@ -50,21 +201,60 @@ const state = new (class {
disabled: this.empty, disabled: this.empty,
iconProps: { iconName: Icons.Delete }, iconProps: { iconName: Icons.Delete },
text: "Clear", 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() { constructor() {
makeAutoObservable(this, { makeAutoObservable(this, {});
selectedPacket: observable.ref,
});
autorun(() => { autorun(() => {
if (GLOBAL_STATE.logs.length === 0) { if (GLOBAL_STATE.logs.length === 0) {
this.selectedPacket = undefined; runInAction(() => this.selection.clear());
} }
}); });
} }
@ -244,21 +434,24 @@ const Row = observer(function Row({
}: GridRowProps) { }: GridRowProps) {
const classes = useClasses(); const classes = useClasses();
const handleClick = useStableCallback(() => { const handlePointerDown = useStableCallback(
runInAction(() => { (e: PointerEvent<HTMLDivElement>) => {
state.selectedPacket = GLOBAL_STATE.logs[rowIndex]; runInAction(() => {
}); e.preventDefault();
}); e.stopPropagation();
state.selection.select(rowIndex, e.ctrlKey, e.shiftKey);
});
}
);
return ( return (
<div <div
className={mergeClasses( className={mergeClasses(
className, className,
classes.row, classes.row,
state.selectedPacket === GLOBAL_STATE.logs[rowIndex] && state.selection.has(rowIndex) && classes.selected
classes.selected
)} )}
onClick={handleClick} onPointerDown={handlePointerDown}
{...rest} {...rest}
/> />
); );
@ -286,12 +479,16 @@ const PacketLog: NextPage = () => {
/> />
</StackItem> </StackItem>
{state.selectedPacket && {state.selection.selectedIndex !== null &&
state.selectedPacket.payload.length > 0 && ( GLOBAL_STATE.logs[state.selection.selectedIndex].payload
.length > 0 && (
<StackItem className={classes.grow} grow> <StackItem className={classes.grow} grow>
<HexViewer <HexViewer
className={classes.hexViewer} className={classes.hexViewer}
data={state.selectedPacket.payload} data={
GLOBAL_STATE.logs[state.selection.selectedIndex]
.payload
}
/> />
</StackItem> </StackItem>
)} )}

View file

@ -6,6 +6,7 @@ export type PacketLogItemDirection = "in" | "out";
export interface PacketLogItem extends AdbPacketData { export interface PacketLogItem extends AdbPacketData {
direction: PacketLogItemDirection; direction: PacketLogItemDirection;
timestamp?: Date;
commandString?: string; commandString?: string;
arg0String?: string; arg0String?: string;
arg1String?: string; arg1String?: string;
@ -48,11 +49,12 @@ export class GlobalState {
appendLog(direction: PacketLogItemDirection, packet: AdbPacketData) { appendLog(direction: PacketLogItemDirection, packet: AdbPacketData) {
(packet as PacketLogItem).direction = direction; (packet as PacketLogItem).direction = direction;
(packet as PacketLogItem).timestamp = new Date();
this.logs.push(packet as PacketLogItem); this.logs.push(packet as PacketLogItem);
} }
clearLog() { 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. // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush.
{ {
"pnpmShrinkwrapHash": "cc0a83ec8632b8d767ce54866717a4770246d3dd", "pnpmShrinkwrapHash": "a4548759bf683ee48a45a7367030c615f7b33683",
"preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f"
} }

View file

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