@samitouri / QOS-React / commits / 45641e98e5

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);