Added owner tree exploration view
Brian Vaughn committed
Feb 7, 2019 at 14:34 UTC
45641e98e5d848b65969866de3e8e9dc7193b21e
11 files changed
+585
-132
src/backend/renderer.js
+10
-5
@@ -543,6 +543,10 @@ export function attach(
543
addOperation(operation);
544
} else {
545
const { displayName, key, type } = getDataForFiber(fiber);
546
+ const { _debugOwner } = fiber;
547
+
548
+ const ownerID =
549
+ _debugOwner !== null ? getFiberID(getPrimaryFiber(_debugOwner)) : 0;
550
551
let encodedDisplayName = ((null: any): Uint8Array);
552
let encodedKey = ((null: any): Uint8Array);
@@ -565,19 +569,20 @@ export function attach(
569
const encodedKeySize = key === null ? 0 : encodedKey.length;
570
571
const operation = new Uint32Array(
568
- 6 + encodedDisplayNameSize + encodedKeySize
572
+ 7 + encodedDisplayNameSize + encodedKeySize
573
);
574
operation[0] = TREE_OPERATION_ADD;
575
operation[1] = id;
576
operation[2] = type;
577
operation[3] = getFiberID(getPrimaryFiber(parentFiber));
574
- operation[4] = encodedDisplayNameSize;
578
+ operation[4] = ownerID;
579
+ operation[5] = encodedDisplayNameSize;
580
if (displayName !== null) {
576
- operation.set(encodedDisplayName, 5);
581
+ operation.set(encodedDisplayName, 6);
582
}
578
- operation[5 + encodedDisplayNameSize] = encodedKeySize;
583
+ operation[6 + encodedDisplayNameSize] = encodedKeySize;
584
if (key !== null) {
580
- operation.set(encodedKey, 5 + encodedDisplayNameSize + 1);
585
+ operation.set(encodedKey, 6 + encodedDisplayNameSize + 1);
586
}
587
addOperation(operation);
588
}
src/devtools/store.js
+6
@@ -202,6 +202,7 @@ export default class Store extends EventEmitter {
202
while (i < operations.length) {
203
let id: number = ((null: any): number);
204
let element: Element = ((null: any): Element);
205
+ let ownerID: number = 0;
206
let parentID: number = ((null: any): number);
207
let parentElement: Element = ((null: any): Element);
208
let type: ElementType = ((null: any): ElementType);
@@ -229,6 +230,7 @@ export default class Store extends EventEmitter {
230
displayName: null,
231
id,
232
key: null,
233
+ ownerID: 0,
234
parentID: 0,
235
type,
236
weight: 0,
@@ -236,6 +238,9 @@ export default class Store extends EventEmitter {
238
239
haveRootsChanged = true;
240
} else {
241
+ ownerID = ((operations[i]: any): number);
242
+ i++;
243
+
244
const displayNameLength = operations[i];
245
i++;
246
const displayName =
@@ -273,6 +278,7 @@ export default class Store extends EventEmitter {
278
displayName,
279
id,
280
key,
281
+ ownerID,
282
parentID: parentElement.id,
283
type,
284
weight: 1,
src/devtools/types.js
+3
@@ -28,6 +28,9 @@ export type Element = {|
28
displayName: string | null,
29
key: number | string | null,
30
31
+ // Owner (if available)
32
+ ownerID: number,
33
+
34
// How many levels deep within the tree is this element?
35
// This determines how much indentation (left padding) should be used in the Elements tree.
36
depth: number,
src/devtools/views/ButtonIcon.js
+17
-1
@@ -4,12 +4,22 @@ import React from 'react';
4
import styles from './ButtonIcon.css';
5
6
type Props = {|
7
- type: 'close' | 'down' | 'search' | 'up' | 'view-dom' | 'view-source',
7
+ type:
8
+ | 'back'
9
+ | 'close'
10
+ | 'down'
11
+ | 'search'
12
+ | 'up'
13
+ | 'view-dom'
14
+ | 'view-source',
15
|};
16
17
export default function ButtonIcon({ type }: Props) {
18
let pathData = null;
19
switch (type) {
20
+ case 'back':
21
+ pathData = PATH_BACK;
22
+ break;
23
case 'close':
24
pathData = PATH_CLOSE;
25
break;
@@ -47,6 +57,12 @@ export default function ButtonIcon({ type }: Props) {
57
);
58
}
59
60
+const PATH_BACK = `
61
+ M22 3H7c-.69 0-1.23.35-1.59.88L0 12l5.41 8.11c.36.53.9.89 1.59.89h15c1.1 0 2-.9
62
+ 2-2V5c0-1.1-.9-2-2-2zm-3 12.59L17.59 17 14 13.41 10.41 17 9 15.59 12.59 12 9 8.41
63
+ 10.41 7 14 10.59 17.59 7 19 8.41 15.41 12 19 15.59z
64
+`;
65
+
66
const PATH_CLOSE =
67
'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z';
68
src/devtools/views/Element.js
+25
-15
@@ -7,6 +7,8 @@ import { createRegExp } from './utils';
7
import { SearchAndSelectionContext } from './SearchAndSelectionContext';
8
import Icon from './Icon';
9
10
+import type { Element } from '../types';
11
+
12
import styles from './Element.css';
13
14
type Props = {
@@ -14,25 +16,32 @@ type Props = {
16
style: Object,
17
};
18
17
-export default function Element({ index, style }: Props) {
19
+export default function ElementView({ index, style }: Props) {
20
const { store } = useContext(TreeContext);
19
- const element = store.getElementAtIndex(index);
20
-
21
- // DevTools are rendered in concurrent mode.
22
- // It's possible the store has updated since the commit that triggered this render.
23
- // So we need to guard against an undefined element.
24
- // TODO: Handle this by switching to a Suspense based approach.
25
- if (element == null) {
26
- return null;
21
+
22
+ const {
23
+ ownerList,
24
+ pushOwnerList,
25
+ selectedElementID,
26
+ selectElementWithID,
27
+ } = useContext(SearchAndSelectionContext);
28
+
29
+ const element =
30
+ ownerList !== null
31
+ ? ((store.getElementByID(ownerList[index]): any): Element)
32
+ : ((store.getElementAtIndex(index): any): Element);
33
+ const { children, depth, displayName, id, key, type } = element;
34
+
35
+ let calculatedDepth = depth;
36
+ if (ownerList !== null) {
37
+ const owner = ((store.getElementByID(ownerList[0]): any): Element);
38
+ calculatedDepth = depth - owner.depth;
39
}
40
29
- // TODO Add click and key handlers for toggling element open/close state.
41
+ const handleDoubleClick = useCallback(() => pushOwnerList(id), [id]);
42
31
- const { children, depth, displayName, id, key, type } = element;
43
+ // TODO Add click and key handlers for toggling element open/close state.
44
33
- const { selectedElementID, selectElementWithID } = useContext(
34
- SearchAndSelectionContext
35
- );
45
const handleClick = useCallback(
46
({ metaKey }) => selectElementWithID(metaKey ? null : id),
47
[id]
@@ -45,9 +54,10 @@ export default function Element({ index, style }: Props) {
54
<div
55
className={isSelected ? styles.SelectedElement : styles.Element}
56
onClick={handleClick}
57
+ onDoubleClick={handleDoubleClick}
58
style={{
59
...style, // "style" comes from react-window
50
- paddingLeft: `${1 + depth}rem`,
60
+ paddingLeft: `${1 + calculatedDepth}rem`,
61
}}
62
>
63
{children.length > 0 && (
src/devtools/views/OwnersStack.css
new
+49
@@ -0,0 +1,49 @@
1
+.OwnerStack {
2
+ flex: 1;
3
+ display: flex;
4
+ align-items: center;
5
+}
6
+
7
+.IconButton {
8
+ border: none;
9
+ background: none;
10
+ display: inline-flex;
11
+ align-items: center;
12
+ padding: 0.25rem;
13
+ cursor: pointer;
14
+ color: var(--color-button);
15
+ flex: 0 0 auto;
16
+}
17
+.IconButton:hover {
18
+ color: var(--color-button-hover);
19
+}
20
+.IconButton:disabled {
21
+ color: var(--color-dimmer);
22
+ cursor: default;
23
+}
24
+
25
+.Component,
26
+.FocusedComponent {
27
+ padding: 0.25rem;
28
+ margin-right: 0.5rem;
29
+ color: var(--color-component-name);
30
+ font-family: var(--font-family-monospace);
31
+ font-size: var(--font-size-normal);
32
+}
33
+
34
+.Component {
35
+ cursor: pointer;
36
+ color: var(--color-component-name);
37
+ background: var(--color-dimmest);
38
+ border-radius: 0.125rem;
39
+}
40
+.Component:hover {
41
+ background: var(--color-dimmer);
42
+}
43
+
44
+.VRule {
45
+ height: 20px;
46
+ width: 1px;
47
+ background-color: var(--color-border);
48
+ margin-right: 0.5rem;
49
+}
src/devtools/views/OwnersStack.js
new
+63
@@ -0,0 +1,63 @@
1
+// @flow
2
+
3
+import React, { useCallback, useContext } from 'react';
4
+import ButtonIcon from './ButtonIcon';
5
+import { SearchAndSelectionContext } from './SearchAndSelectionContext';
6
+import { TreeContext } from './context';
7
+
8
+import type { Element } from '../types';
9
+
10
+import styles from './OwnersStack.css';
11
+
12
+export default function OwnerStack() {
13
+ const { clearOwnerList, ownerIDStack } = useContext(
14
+ SearchAndSelectionContext
15
+ );
16
+
17
+ // $FlowFixMe "Missing type annotation for U" whatever that means
18
+ const elements = ownerIDStack.map(id => <ElementView key={id} id={id} />);
19
+
20
+ return (
21
+ <div className={styles.OwnerStack}>
22
+ <button
23
+ className={styles.IconButton}
24
+ onClick={clearOwnerList}
25
+ title="Back to tree view"
26
+ >
27
+ <ButtonIcon type="back" />
28
+ </button>
29
+ <div className={styles.VRule} />
30
+ {elements}
31
+ </div>
32
+ );
33
+}
34
+
35
+type Props = {
36
+ id: number,
37
+};
38
+
39
+function ElementView({ id }: Props) {
40
+ const { ownerIDStack, popToOwnerList } = useContext(
41
+ SearchAndSelectionContext
42
+ );
43
+ const { store } = useContext(TreeContext);
44
+ const { displayName } = ((store.getElementByID(id): any): Element);
45
+
46
+ const isCurrentlyFocusedOwner = ownerIDStack[ownerIDStack.length - 1] === id;
47
+
48
+ const handleClick = useCallback(() => popToOwnerList(id), [
49
+ id,
50
+ popToOwnerList,
51
+ ]);
52
+
53
+ return (
54
+ <span
55
+ className={
56
+ isCurrentlyFocusedOwner ? styles.FocusedComponent : styles.Component
57
+ }
58
+ onClick={handleClick}
59
+ >
60
+ {displayName}
61
+ </span>
62
+ );
63
+}
src/devtools/views/SearchAndSelectionContext.js
+345
-84
@@ -1,7 +1,7 @@
1
// @flow
2
3
-// This context contains both the current search state and the selected element.
4
-// These values are combined into a single context because changes in one often impact the other.
3
+// This context contains both the search, owners stack, and selected element states.
4
+// These values are combined into a single context because changes in one often impact the others.
5
// Combining them enables us to avoid cascading renders.
6
7
import React, {
@@ -19,7 +19,9 @@ import Store from '../store';
19
import type { Element } from 'src/devtools/types';
20
21
type Context = {|
22
- // Read current context values:
22
+ // Read current context values
23
+ ownerIDStack: Array<number>,
24
+ ownerList: Array<number> | null,
25
searchIndex: number | null,
26
searchResults: Array<number>,
27
searchText: string,
@@ -27,10 +29,14 @@ type Context = {|
29
selectedElementIndex: number | null,
30
31
// Update current context values:
32
+ clearOwnerList: () => void,
33
decrementSearchIndex(): void,
34
incrementSearchIndex(): void,
35
+ popToOwnerList(elementID: number): void,
36
+ pushOwnerList(elementID: number): void,
37
selectElementWithID(id: number | null): void,
33
- selectElementAtIndex(index: number | null): void,
38
+ selectNextElementInTree(): void,
39
+ selectPreviousElementInTree(): void,
40
updateSearchText(searchText: string): void,
41
|};
42
@@ -41,6 +47,8 @@ const SearchAndSelectionContext = createContext<Context>(
47
SearchAndSelectionContext.displayName = 'SearchAndSelectionContext';
48
49
type State = {|
50
+ ownerIDStack: Array<number>,
51
+ ownerList: Array<number> | null,
52
searchIndex: number | null,
53
searchResults: Array<number>,
54
searchText: string,
@@ -50,11 +58,15 @@ type State = {|
58
59
type Action = {|
60
type:
61
+ | 'CLEAR_OWNER_LIST'
62
| 'DECREMENT_SEARCH_INDEX'
63
| 'INCREMENT_SEARCH_INDEX'
55
- | 'REFINE_SEARCH_RESULTS'
64
+ | 'POP_TO_OWNER_LIST'
65
+ | 'PUSH_OWNER_LIST'
66
+ | 'REFINE_AFTER_MUTATION'
67
| 'SELECT_ELEMENT_WITH_ID'
57
- | 'SELECT_ELEMENT_AT_INDEX'
68
+ | 'SELECT_NEXT_ELEMENT_IN_TREE'
69
+ | 'SELECT_PREVIOUS_ELEMENT_IN_TREE'
70
| 'UPDATE_SEARCH_TEXT',
71
payload?: any,
72
|};
@@ -73,16 +85,70 @@ function SearchAndSelectionController({ children }: Props) {
85
const reducer = useMemo(
86
() =>
87
function reducer(state: State, action: Action): State {
76
- const { searchIndex, searchResults, searchText } = state;
88
+ const {
89
+ ownerIDStack,
90
+ ownerList,
91
+ searchIndex,
92
+ searchResults,
93
+ searchText,
94
+ selectedElementID,
95
+ selectedElementIndex,
96
+ } = state;
97
const { payload, type } = action;
98
79
- let newSearchIndex,
99
+ let element,
100
+ elementID,
101
+ newOwnerList,
102
+ newOwnerIDStack,
103
+ newSearchIndex,
104
newSearchResults,
105
newSearchText,
82
- newSelectedElementID;
106
+ newSelectedElementID,
107
+ newSelectedElementIndex;
108
+
109
+ const updateSelectedElementIndexHelper = (
110
+ newSelectedElementIndex: number
111
+ ) => {
112
+ if (ownerList !== null) {
113
+ newSelectedElementID =
114
+ newSelectedElementIndex !== null
115
+ ? ownerList[newSelectedElementIndex]
116
+ : null;
117
+
118
+ return {
119
+ ...state,
120
+ selectedElementID: newSelectedElementID,
121
+ selectedElementIndex: newSelectedElementIndex,
122
+ };
123
+ } else {
124
+ element =
125
+ newSelectedElementIndex !== null
126
+ ? store.getElementAtIndex(newSelectedElementIndex)
127
+ : null;
128
+
129
+ return {
130
+ ...state,
131
+ selectedElementID: element !== null ? element.id : null,
132
+ selectedElementIndex: newSelectedElementIndex,
133
+ };
134
+ }
135
+ };
136
137
switch (type) {
138
+ case 'CLEAR_OWNER_LIST':
139
+ return {
140
+ ...state,
141
+ ownerList: null,
142
+ ownerIDStack: [],
143
+ };
144
+
145
case 'DECREMENT_SEARCH_INDEX':
146
+ if (ownerList !== null) {
147
+ throw Error(
148
+ `Can't update search index while owner list is active`
149
+ );
150
+ }
151
+
152
if (searchResults.length === 0) return state;
153
154
newSearchIndex =
@@ -101,6 +167,12 @@ function SearchAndSelectionController({ children }: Props) {
167
};
168
169
case 'INCREMENT_SEARCH_INDEX':
170
+ if (ownerList !== null) {
171
+ throw Error(
172
+ `Can't update search index while owner list is active`
173
+ );
174
+ }
175
+
176
if (searchResults.length === 0) return state;
177
178
newSearchIndex =
@@ -118,108 +190,245 @@ function SearchAndSelectionController({ children }: Props) {
190
),
191
};
192
121
- case 'REFINE_SEARCH_RESULTS':
193
+ case 'POP_TO_OWNER_LIST':
194
+ elementID = ((payload: any): number);
195
+ return {
196
+ ...state,
197
+ ownerList: calculateCurrentOwnerList(
198
+ store,
199
+ elementID,
200
+ elementID,
201
+ []
202
+ ),
203
+ ownerIDStack: ownerIDStack.slice(
204
+ 0,
205
+ ownerIDStack.indexOf(elementID) + 1
206
+ ),
207
+ selectedElementIndex: null,
208
+ selectedElementID: null,
209
+ };
210
+
211
+ case 'PUSH_OWNER_LIST':
212
+ elementID = ((payload: any): number);
213
+ if (ownerIDStack.includes(elementID)) return state;
214
+
215
+ // Add this new owner, and fill in the owners above it as well.
216
+ const ownerIDsToConcat = [];
217
+ let currentOwnerID = elementID;
218
+ while (
219
+ currentOwnerID !== 0 &&
220
+ ownerIDStack[ownerIDStack.length - 1] !== currentOwnerID
221
+ ) {
222
+ ownerIDsToConcat.unshift(currentOwnerID);
223
+ currentOwnerID = ((store.getElementByID(
224
+ currentOwnerID
225
+ ): any): Element).ownerID;
226
+ }
227
+
228
+ return {
229
+ ...state,
230
+ ownerList: calculateCurrentOwnerList(
231
+ store,
232
+ elementID,
233
+ elementID,
234
+ []
235
+ ),
236
+ ownerIDStack: ownerIDStack.concat(ownerIDsToConcat),
237
+ selectedElementIndex: null,
238
+ selectedElementID: null,
239
+ searchIndex: null,
240
+ searchResults: [],
241
+ searchText: '',
242
+ };
243
+
244
+ case 'REFINE_AFTER_MUTATION':
245
const [
246
addedElementIDs,
247
removedElementIDs,
248
] = ((payload: any): Array<Uint32Array>);
249
127
- newSearchResults = searchResults;
250
+ newOwnerList = ownerList;
251
+ newOwnerIDStack = ownerIDStack;
252
newSearchIndex = searchIndex;
129
-
130
- removedElementIDs.forEach(id => {
131
- // Prune this item from the search results.
132
- const index = newSearchResults.indexOf(id);
133
- if (index >= 0) {
134
- newSearchResults = newSearchResults
135
- .slice(0, index)
136
- .concat(newSearchResults.slice(index + 1));
137
-
138
- // If the results are now empty, also deselect things.
139
- if (newSearchResults.length === 0) {
140
- newSearchIndex = null;
141
- } else if (
142
- ((newSearchIndex: any): number) >= newSearchResults.length
143
- ) {
144
- newSearchIndex = newSearchResults.length - 1;
253
+ newSearchResults = searchResults;
254
+ newSearchText = searchText;
255
+ newSelectedElementID = selectedElementID;
256
+ newSelectedElementIndex = selectedElementIndex;
257
+
258
+ if (searchText !== '') {
259
+ removedElementIDs.forEach(id => {
260
+ // Prune this item from the search results.
261
+ const index = newSearchResults.indexOf(id);
262
+ if (index >= 0) {
263
+ newSearchResults = newSearchResults
264
+ .slice(0, index)
265
+ .concat(newSearchResults.slice(index + 1));
266
+
267
+ // If the results are now empty, also deselect things.
268
+ if (newSearchResults.length === 0) {
269
+ newSearchIndex = null;
270
+ } else if (
271
+ ((newSearchIndex: any): number) >= newSearchResults.length
272
+ ) {
273
+ newSearchIndex = newSearchResults.length - 1;
274
+ }
275
}
146
- }
147
- });
148
-
149
- addedElementIDs.forEach(id => {
150
- const { displayName } = ((store.getElementByID(
151
- id
152
- ): any): Element);
276
+ });
277
154
- // Add this item to the search results if it matches.
155
- const regExp = createRegExp(searchText);
156
- if (displayName !== null && regExp.test(displayName)) {
157
- const newElementIndex = ((store.getIndexOfElementID(
278
+ addedElementIDs.forEach(id => {
279
+ const { displayName } = ((store.getElementByID(
280
id
159
- ): any): number);
160
-
161
- let foundMatch = false;
162
- for (let index = 0; index < newSearchResults.length; index++) {
163
- const id = newSearchResults[index];
164
- if (
165
- newElementIndex <
166
- ((store.getIndexOfElementID(id): any): number)
281
+ ): any): Element);
282
+
283
+ // Add this item to the search results if it matches.
284
+ const regExp = createRegExp(searchText);
285
+ if (displayName !== null && regExp.test(displayName)) {
286
+ const newElementIndex = ((store.getIndexOfElementID(
287
+ id
288
+ ): any): number);
289
+
290
+ let foundMatch = false;
291
+ for (
292
+ let index = 0;
293
+ index < newSearchResults.length;
294
+ index++
295
) {
168
- foundMatch = true;
169
- newSearchResults = newSearchResults
170
- .slice(0, index)
171
- .concat(id)
172
- .concat(newSearchResults.slice(index));
173
- break;
296
+ const id = newSearchResults[index];
297
+ if (
298
+ newElementIndex <
299
+ ((store.getIndexOfElementID(id): any): number)
300
+ ) {
301
+ foundMatch = true;
302
+ newSearchResults = newSearchResults
303
+ .slice(0, index)
304
+ .concat(id)
305
+ .concat(newSearchResults.slice(index));
306
+ break;
307
+ }
308
+ }
309
+ if (!foundMatch) {
310
+ newSearchResults = newSearchResults.concat(id);
311
}
312
+
313
+ newSearchIndex = newSearchIndex === null ? 0 : newSearchIndex;
314
}
176
- if (!foundMatch) {
177
- newSearchResults = newSearchResults.concat(id);
315
+ });
316
+
317
+ newSelectedElementID =
318
+ newSearchIndex !== null
319
+ ? newSearchResults[newSearchIndex]
320
+ : null;
321
+ newSelectedElementIndex =
322
+ newSelectedElementID !== null
323
+ ? store.getIndexOfElementID(newSelectedElementID)
324
+ : null;
325
+ }
326
+
327
+ // If an item in the owner stack has been removed from the tree, unwind it.
328
+ if (ownerIDStack.length > 0) {
329
+ let indexOfRemovedItem = -1;
330
+ for (let i = 0; i < ownerIDStack.length; i++) {
331
+ if (removedElementIDs.includes(ownerIDStack[i])) {
332
+ indexOfRemovedItem = i;
333
+ break;
334
}
335
+ }
336
180
- newSearchIndex = newSearchIndex === null ? 0 : newSearchIndex;
337
+ if (indexOfRemovedItem >= 0) {
338
+ newOwnerIDStack = ownerIDStack.slice(0, indexOfRemovedItem);
339
}
182
- });
340
184
- newSelectedElementID =
185
- newSearchIndex !== null ? newSearchResults[newSearchIndex] : null;
341
+ if (newOwnerIDStack.length === 0) {
342
+ newOwnerList = null;
343
+ } else {
344
+ elementID = newOwnerIDStack[newOwnerIDStack.length - 1];
345
+
346
+ newOwnerList = calculateCurrentOwnerList(
347
+ store,
348
+ elementID,
349
+ elementID,
350
+ []
351
+ );
352
+
353
+ newSelectedElementIndex =
354
+ newSelectedElementID !== null
355
+ ? newOwnerList.indexOf(newSelectedElementID)
356
+ : null;
357
+ }
358
+ }
359
360
return {
361
...state,
362
+ ownerIDStack: newOwnerIDStack,
363
+ ownerList: newOwnerList,
364
searchIndex: newSearchIndex,
365
searchResults: newSearchResults,
366
selectedElementID: newSelectedElementID,
192
- selectedElementIndex:
193
- newSelectedElementID !== null
194
- ? store.getIndexOfElementID(newSelectedElementID)
195
- : null,
367
+ selectedElementIndex: newSelectedElementIndex,
368
};
369
370
case 'SELECT_ELEMENT_WITH_ID':
371
newSelectedElementID = ((payload: any): number | null);
372
+ newSelectedElementIndex = null;
373
+
374
+ if (newSelectedElementID !== null) {
375
+ if (ownerList !== null) {
376
+ newSelectedElementIndex = ownerList.indexOf(
377
+ newSelectedElementID
378
+ );
379
+ } else {
380
+ newSelectedElementIndex = store.getIndexOfElementID(
381
+ newSelectedElementID
382
+ );
383
+ }
384
+ }
385
+
386
+ const isInOwnerList =
387
+ newSelectedElementID !== null &&
388
+ ownerList !== null &&
389
+ ownerList.includes(newSelectedElementID);
390
391
return {
392
...state,
393
+ ownerIDStack: isInOwnerList ? ownerIDStack : [],
394
+ ownerList: isInOwnerList ? ownerList : null,
395
selectedElementID: newSelectedElementID,
204
- selectedElementIndex:
205
- newSelectedElementID !== null
206
- ? store.getIndexOfElementID(newSelectedElementID)
207
- : null,
396
+ selectedElementIndex: newSelectedElementIndex,
397
};
398
210
- case 'SELECT_ELEMENT_AT_INDEX':
211
- newSearchIndex = ((payload: any): number | null);
212
-
213
- const element =
214
- newSearchIndex !== null
215
- ? store.getElementAtIndex(newSearchIndex)
216
- : null;
399
+ case 'SELECT_NEXT_ELEMENT_IN_TREE':
400
+ if (selectedElementIndex === null) {
401
+ return state;
402
+ } else if (ownerList !== null) {
403
+ return updateSelectedElementIndexHelper(
404
+ selectedElementIndex + 1 < ownerList.length
405
+ ? selectedElementIndex + 1
406
+ : selectedElementIndex
407
+ );
408
+ } else {
409
+ return updateSelectedElementIndexHelper(
410
+ selectedElementIndex + 1 < store.numElements
411
+ ? selectedElementIndex + 1
412
+ : selectedElementIndex
413
+ );
414
+ }
415
218
- return {
219
- ...state,
220
- selectedElementID: element !== null ? element.id : null,
221
- selectedElementIndex: newSearchIndex,
222
- };
416
+ case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
417
+ if (selectedElementIndex === null) {
418
+ return state;
419
+ } else if (ownerList !== null) {
420
+ return updateSelectedElementIndexHelper(
421
+ selectedElementIndex > 0
422
+ ? selectedElementIndex - 1
423
+ : selectedElementIndex
424
+ );
425
+ } else {
426
+ return updateSelectedElementIndexHelper(
427
+ selectedElementIndex > 0
428
+ ? selectedElementIndex - 1
429
+ : selectedElementIndex
430
+ );
431
+ }
432
433
case 'UPDATE_SEARCH_TEXT':
434
newSearchIndex = searchIndex;
@@ -275,6 +484,8 @@ function SearchAndSelectionController({ children }: Props) {
484
);
485
486
const [state, dispatch] = useReducer(reducer, {
487
+ ownerIDStack: [],
488
+ ownerList: null,
489
searchIndex: null,
490
searchResults: [],
491
searchText: '',
@@ -282,6 +493,10 @@ function SearchAndSelectionController({ children }: Props) {
493
selectedElementIndex: null,
494
});
495
496
+ const clearOwnerList = useCallback(
497
+ () => dispatch({ type: 'CLEAR_OWNER_LIST' }),
498
+ [dispatch]
499
+ );
500
const decrementSearchIndex = useCallback(
501
() => dispatch({ type: 'DECREMENT_SEARCH_INDEX' }),
502
[dispatch]
@@ -290,14 +505,27 @@ function SearchAndSelectionController({ children }: Props) {
505
() => dispatch({ type: 'INCREMENT_SEARCH_INDEX' }),
506
[dispatch]
507
);
508
+ const popToOwnerList = useCallback(
509
+ (elementID: number) =>
510
+ dispatch({ type: 'POP_TO_OWNER_LIST', payload: elementID }),
511
+ [dispatch]
512
+ );
513
+ const pushOwnerList = useCallback(
514
+ (elementID: number) =>
515
+ dispatch({ type: 'PUSH_OWNER_LIST', payload: elementID }),
516
+ [dispatch]
517
+ );
518
const selectElementWithID = useCallback(
519
(id: number | null) =>
520
dispatch({ type: 'SELECT_ELEMENT_WITH_ID', payload: id }),
521
[dispatch]
522
);
298
- const selectElementAtIndex = useCallback(
299
- (index: number | null) =>
300
- dispatch({ type: 'SELECT_ELEMENT_AT_INDEX', payload: index }),
523
+ const selectNextElementInTree = useCallback(
524
+ () => dispatch({ type: 'SELECT_NEXT_ELEMENT_IN_TREE' }),
525
+ [dispatch]
526
+ );
527
+ const selectPreviousElementInTree = useCallback(
528
+ () => dispatch({ type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE' }),
529
[dispatch]
530
);
531
const updateSearchText = useCallback(
@@ -308,16 +536,22 @@ function SearchAndSelectionController({ children }: Props) {
536
537
const value = useMemo(
538
() => ({
539
+ ownerList: state.ownerList,
540
+ ownerIDStack: state.ownerIDStack,
541
searchIndex: state.searchIndex,
542
searchResults: state.searchResults,
543
searchText: state.searchText,
544
selectedElementID: state.selectedElementID,
545
selectedElementIndex: state.selectedElementIndex,
546
547
+ clearOwnerList,
548
decrementSearchIndex,
549
incrementSearchIndex,
550
+ popToOwnerList,
551
+ pushOwnerList,
552
selectElementWithID,
320
- selectElementAtIndex,
553
+ selectNextElementInTree,
554
+ selectPreviousElementInTree,
555
updateSearchText,
556
}),
557
[state]
@@ -329,12 +563,12 @@ function SearchAndSelectionController({ children }: Props) {
563
addedElementIDs,
564
removedElementIDs,
565
]: Array<Uint32Array>) => {
332
- if (!state.searchText) {
566
+ if (!state.searchText && state.ownerList === null) {
567
return;
568
}
569
570
dispatch({
337
- type: 'REFINE_SEARCH_RESULTS',
571
+ type: 'REFINE_AFTER_MUTATION',
572
payload: [addedElementIDs, removedElementIDs],
573
});
574
};
@@ -351,6 +585,33 @@ function SearchAndSelectionController({ children }: Props) {
585
);
586
}
587
588
+function calculateCurrentOwnerList(
589
+ store: Store,
590
+ rootOwnerID: number,
591
+ elementID: number,
592
+ ownerList: Array<number>
593
+): Array<number> {
594
+ if (elementID === rootOwnerID) {
595
+ ownerList.push(elementID);
596
+ const { children } = ((store.getElementByID(elementID): any): Element);
597
+ children.forEach(childID =>
598
+ calculateCurrentOwnerList(store, rootOwnerID, childID, ownerList)
599
+ );
600
+ } else {
601
+ const { children, ownerID } = ((store.getElementByID(
602
+ elementID
603
+ ): any): Element);
604
+ if (ownerID === rootOwnerID) {
605
+ ownerList.push(elementID);
606
+ children.forEach(childID =>
607
+ calculateCurrentOwnerList(store, rootOwnerID, childID, ownerList)
608
+ );
609
+ }
610
+ }
611
+
612
+ return ownerList;
613
+}
614
+
615
function recursivelySearchTree(
616
store: Store,
617
elementID: number,
src/devtools/views/SelectedElement.js
+28
-10
@@ -1,6 +1,12 @@
1
// @flow
2
3
-import React, { useContext, useLayoutEffect, useRef, useState } from 'react';
3
+import React, {
4
+ useCallback,
5
+ useContext,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+} from 'react';
10
import { SearchAndSelectionContext } from './SearchAndSelectionContext';
11
import { BridgeContext, StoreContext } from './context';
12
import ButtonIcon from './ButtonIcon';
@@ -75,8 +81,6 @@ type InspectedElementViewProps = {|
81
|};
82
83
function InspectedElementView({ inspectedElement }: InspectedElementViewProps) {
78
- const { selectElementWithID } = useContext(SearchAndSelectionContext);
79
-
84
let { context, hooks, owners, props, state } = inspectedElement;
85
86
return (
@@ -90,14 +94,11 @@ function InspectedElementView({ inspectedElement }: InspectedElementViewProps) {
94
<div className={styles.Owners}>
95
<div>owner stack</div>
96
{owners.map(owner => (
93
- <div
97
+ <OwnerView
98
key={owner.id}
95
- className={styles.Owner}
96
- onClick={() => selectElementWithID(owner.id)}
97
- title={owner.displayName}
98
- >
99
- {owner.displayName}
100
- </div>
99
+ displayName={owner.displayName}
100
+ id={owner.id}
101
+ />
102
))}
103
</div>
104
)}
@@ -105,6 +106,23 @@ function InspectedElementView({ inspectedElement }: InspectedElementViewProps) {
106
);
107
}
108
109
+function OwnerView({ displayName, id }: { displayName: string, id: number }) {
110
+ const { selectElementWithID } = useContext(SearchAndSelectionContext);
111
+
112
+ const handleClick = useCallback(() => selectElementWithID(id), [id]);
113
+
114
+ return (
115
+ <div
116
+ key={id}
117
+ className={styles.Owner}
118
+ onClick={handleClick}
119
+ title={displayName}
120
+ >
121
+ {displayName}
122
+ </div>
123
+ );
124
+}
125
+
126
function hydrateHelper(dehydratedData: DehydratedData | null): Object | null {
127
if (dehydratedData !== null) {
128
return hydrate(dehydratedData.data, dehydratedData.cleaned);
src/devtools/views/Tree.js
+37
-17
@@ -1,11 +1,18 @@
1
// @flow
2
3
-import React, { useContext, useEffect, useLayoutEffect, useRef } from 'react';
3
+import React, {
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useLayoutEffect,
8
+ useRef,
9
+} from 'react';
10
import AutoSizer from 'react-virtualized-auto-sizer';
11
import { FixedSizeList } from 'react-window';
12
import { SearchAndSelectionContext } from './SearchAndSelectionContext';
13
import ButtonIcon from './ButtonIcon';
14
import Element from './Element';
15
+import OwnersStack from './OwnersStack';
16
import SearchInput from './SearchInput';
17
import { TreeContext } from './context';
18
@@ -14,9 +21,13 @@ import styles from './Tree.css';
21
type Props = {||};
22
23
export default function Tree(props: Props) {
17
- const { selectedElementIndex, selectElementAtIndex } = useContext(
18
- SearchAndSelectionContext
19
- );
24
+ const {
25
+ ownerIDStack,
26
+ ownerList,
27
+ selectedElementIndex,
28
+ selectNextElementInTree,
29
+ selectPreviousElementInTree,
30
+ } = useContext(SearchAndSelectionContext);
31
const treeContext = useContext(TreeContext);
32
const listRef = useRef<FixedSizeList<any>>();
33
@@ -30,22 +41,17 @@ export default function Tree(props: Props) {
41
42
// Navigate the tree with up/down arrow keys.
43
useEffect(() => {
44
+ // TODO Use selectNextElement / selectPreviousElement (with dispatch)
45
+
46
const handleKeyDown = event => {
47
// eslint-disable-next-line default-case
48
switch (event.key) {
49
case 'ArrowDown':
37
- if (
38
- selectedElementIndex !== null &&
39
- selectedElementIndex + 1 < treeContext.size
40
- ) {
41
- selectElementAtIndex(((selectedElementIndex: any): number) + 1);
42
- }
50
+ selectNextElementInTree();
51
event.preventDefault();
52
break;
53
case 'ArrowUp':
46
- if (selectedElementIndex !== null && selectedElementIndex > 0) {
47
- selectElementAtIndex(((selectedElementIndex: any): number) - 1);
48
- }
54
+ selectPreviousElementInTree();
55
event.preventDefault();
56
break;
57
}
@@ -56,12 +62,26 @@ export default function Tree(props: Props) {
62
return () => {
63
window.removeEventListener('keydown', handleKeyDown);
64
};
59
- }, [selectedElementIndex, selectElementAtIndex, treeContext]);
65
+ }, [
66
+ selectedElementIndex,
67
+ selectNextElementInTree,
68
+ selectPreviousElementInTree,
69
+ treeContext,
70
+ ]);
71
+
72
+ const itemCount = ownerList !== null ? ownerList.length : treeContext.size;
73
+
74
+ // Let react-window know to re-render any time the underlying tree data changes.
75
+ // This includes the owner context, since it controls a filtered view of the tree.
76
+ const itemData = useMemo(() => ({ treeContext, ownerList }), [
77
+ treeContext,
78
+ ownerList,
79
+ ]);
80
81
return (
82
<div className={styles.Tree}>
83
<div className={styles.SearchInput}>
64
- <SearchInput />
84
+ {ownerIDStack.length > 0 ? <OwnersStack /> : <SearchInput />}
85
<button
86
className={styles.IconButton}
87
title="Select an element in the page to inspect it"
@@ -75,8 +95,8 @@ export default function Tree(props: Props) {
95
<FixedSizeList
96
className={styles.List}
97
height={height}
78
- itemCount={treeContext.size}
79
- itemData={treeContext}
98
+ itemCount={itemCount}
99
+ itemData={itemData}
100
itemSize={20}
101
ref={listRef}
102
width={width}
src/devtools/views/root.css
+2
@@ -12,6 +12,7 @@
12
--theme-light-component-name-inverted: #ffffff;
13
--theme-light-dim: #777d88;
14
--theme-light-dimmer: #cfd1d5;
15
+ --theme-light-dimmest: #eff0f1;
16
--theme-light-jsx-arrow-brackets: #333333;
17
--theme-light-jsx-arrow-brackets-inverted: rgba(255, 255, 255, 0.7);
18
--theme-light-search-match: yellow;
@@ -34,6 +35,7 @@
35
--color-component-name-inverted: var(--theme-light-component-name-inverted);
36
--color-dim: var(--theme-light-dim);
37
--color-dimmer: var(--theme-light-dimmer);
38
+ --color-dimmest: var(--theme-light-dimmest);
39
--color-search-match: var(--theme-light-search-match);
40
--color-search-match-current: var(--theme-light-search-match-current);
41
--color-tree-jsx-arrow-brackets: var(--theme-light-jsx-arrow-brackets);