Repository: preactjs/preact-devtools
Pull request #210 (2020-05-21 19:53:33)

Add support for view source
Adds an icon to the sidebar to jump to the component source in the debugger.


Patch:
diff --git a/src/adapter/10/renderer.ts b/src/adapter/10/renderer.ts
index 8164111..ad8c5bb 100644
--- a/src/adapter/10/renderer.ts
+++ b/src/adapter/10/renderer.ts
@@ -22,10 +22,10 @@ import {
 	getHookState,
 } from "./vnode";
 import { shouldFilter } from "./filter";
-import { ID } from "../../view/store/types";
+import { ID, DevNodeType } from "../../view/store/types";
 import { traverse, setIn, SerializedVNode, setInCopy } from "./utils";
 import { FilterState } from "../adapter/filter";
-import { Renderer, Elements } from "../renderer";
+import { Renderer } from "../renderer";
 import {
 	createIdMappingState,
 	getVNodeById,
@@ -57,19 +57,19 @@ const forwardRefReg = /^ForwardRef\(/;
  * Get the type of a vnode. The devtools uses these constants to differentiate
  * between the various forms of components.
  */
-export function getDevtoolsType(vnode: VNode): Elements {
+export function getDevtoolsType(vnode: VNode): DevNodeType {
 	if (typeof vnode.type == "function" && vnode.type !== Fragment) {
 		const name = vnode.type.displayName || "";
-		if (memoReg.test(name)) return Elements.MEMO;
-		if (forwardRefReg.test(name)) return Elements.FORWARD_REF;
-		if (isSuspenseVNode(vnode)) return Elements.SUSPENSE;
+		if (memoReg.test(name)) return DevNodeType.Memo;
+		if (forwardRefReg.test(name)) return DevNodeType.ForwardRef;
+		if (isSuspenseVNode(vnode)) return DevNodeType.Suspense;
 
 		// TODO: Provider and Consumer
 		return vnode.type.prototype && vnode.type.prototype.render
-			? Elements.CLASS_COMPONENT
-			: Elements.FUNCTION_COMPONENT;
+			? DevNodeType.ClassComponent
+			: DevNodeType.FunctionComponent;
 	}
-	return Elements.HTML_ELEMENT;
+	return DevNodeType.Element;
 }
 
 export function isVNode(x: any): x is VNode {
diff --git a/src/adapter/adapter/adapter.ts b/src/adapter/adapter/adapter.ts
index c21a1f5..34ce350 100644
--- a/src/adapter/adapter/adapter.ts
+++ b/src/adapter/adapter/adapter.ts
@@ -169,4 +169,19 @@ export function createAdapter(port: PortPageHook, renderer: Renderer) {
 			}
 		}
 	});
+
+	listen("view-source", id => {
+		const vnode = renderer.getVNodeById(id);
+		const hook: DevtoolsHook = (window as any).__PREACT_DEVTOOLS__;
+
+		if (vnode && typeof vnode.type === "function") {
+			const { type } = vnode;
+			hook.$type =
+				type && type.prototype && type.prototype.render
+					? type.prototype.render
+					: type;
+		} else {
+			hook.$type = null;
+		}
+	});
 }
diff --git a/src/adapter/debug.ts b/src/adapter/debug.ts
index 0194b0f..788fba3 100644
--- a/src/adapter/debug.ts
+++ b/src/adapter/debug.ts
@@ -1,7 +1,6 @@
 import { MsgTypes } from "./events/events";
 import { parseTable, flushTable } from "./string-table";
-import { Elements } from "./renderer";
-import { ID } from "../view/store/types";
+import { ID, DevNodeType } from "../view/store/types";
 
 export interface ParsedMsg {
 	rootId: number;
@@ -138,8 +137,8 @@ export function fromSnapshot(events: string[]): number[] {
 					MsgTypes.ADD_VNODE,
 					+m[1],
 					m[2][0] !== m[2][0].toLowerCase()
-						? Elements.FUNCTION_COMPONENT
-						: Elements.HTML_ELEMENT,
+						? DevNodeType.FunctionComponent
+						: DevNodeType.Element,
 					+m[3],
 					9999,
 					idx,
diff --git a/src/adapter/hook.ts b/src/adapter/hook.ts
index 764451b..0e1c817 100644
--- a/src/adapter/hook.ts
+++ b/src/adapter/hook.ts
@@ -33,6 +33,7 @@ export interface DevtoolEvents {
 	"update-filter": RawFilterState;
 	"load-host-selection": null;
 	"inspect-host-node": null;
+	"view-source": ID;
 	copy: string;
 	highlight: ID | null;
 	log: { id: ID; children: ID[] };
@@ -54,6 +55,8 @@ export type EmitFn = <K extends keyof DevtoolEvents>(
 export interface DevtoolsHook {
 	/** Currently selected node in the native browser's Elements panel */
 	$0: HTMLElement | null;
+	/** Function to inspect for view source feature */
+	$type: Function | null;
 	connected: boolean;
 	emit: EmitFn;
 	listen: (fn: (name: string, cb: any) => any) => void;
@@ -150,6 +153,7 @@ export function createHook(port: PortPageHook): DevtoolsHook {
 
 	return {
 		$0: null,
+		$type: null,
 		renderers,
 		get connected() {
 			return status === "connected";
diff --git a/src/adapter/renderer.ts b/src/adapter/renderer.ts
index b70aaed..72b52d8 100644
--- a/src/adapter/renderer.ts
+++ b/src/adapter/renderer.ts
@@ -34,12 +34,3 @@ export interface Renderer {
 	// Hooks
 	updateHook?(id: ID, index: number, value: any): void; // V3
 }
-
-export enum Elements {
-	HTML_ELEMENT = 1,
-	CLASS_COMPONENT = 2,
-	FUNCTION_COMPONENT = 3,
-	FORWARD_REF = 4,
-	MEMO = 5,
-	SUSPENSE = 6,
-}
diff --git a/src/shells/shared/panel/panel.ts b/src/shells/shared/panel/panel.ts
index 0043c8a..786544b 100644
--- a/src/shells/shared/panel/panel.ts
+++ b/src/shells/shared/panel/panel.ts
@@ -105,6 +105,23 @@ const destroy = store.subscribe((type, data) => {
 	}
 
 	port.postMessage({ type, data, source: DevtoolsPanelName });
+
+	if (type === "view-source") {
+		// Wait for the content-script to set `__PREACT_DEVTOOLS__.$type`
+		// to the correct function
+		setTimeout(() => {
+			chrome.devtools.inspectedWindow.eval(`
+				(function() {
+					const type = window.__PREACT_DEVTOOLS__
+						&& window.__PREACT_DEVTOOLS__.$type;
+		
+					if (type != null) {
+						inspect(type);
+					}
+				})()
+			`);
+		}, 100);
+	}
 });
 
 port.onDisconnect.addListener(() => {
diff --git a/src/view/components/icons.tsx b/src/view/components/icons.tsx
index de24440..dc5ff08 100644
--- a/src/view/components/icons.tsx
+++ b/src/view/components/icons.tsx
@@ -286,3 +286,16 @@ export function FireIcon({ size = "s" }: Props) {
 		</Fragment>,
 	);
 }
+
+export function CodeIcon({ size = "s" }: Props) {
+	return createSvgIcon(
+		size,
+		<Fragment>
+			<path d="M0 0h24v24H0V0z" fill="none" />
+			<path
+				d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"
+				fill="currentColor"
+			/>
+		</Fragment>,
+	);
+}
diff --git a/src/view/components/profiler/components/SidebarHeader.tsx b/src/view/components/profiler/components/SidebarHeader.tsx
index 8a6e05a..77fe813 100644
--- a/src/view/components/profiler/components/SidebarHeader.tsx
+++ b/src/view/components/profiler/components/SidebarHeader.tsx
@@ -1,4 +1,4 @@
-import { h } from "preact";
+import { h, Fragment } from "preact";
 import { Actions } from "../../Actions";
 import s from "../../sidebar/Sidebar.css";
 import {
@@ -9,7 +9,8 @@ import {
 import { ComponentName } from "../../ComponentName";
 import { useCallback } from "preact/hooks";
 import { IconBtn } from "../../IconBtn";
-import { BugIcon, InspectNativeIcon } from "../../icons";
+import { BugIcon, InspectNativeIcon, CodeIcon } from "../../icons";
+import { DevNodeType } from "../../../store/types";
 
 export function SidebarHeader() {
 	const store = useStore();
@@ -21,20 +22,40 @@ export function SidebarHeader() {
 	const inspectHostNode = useCallback(() => {
 		emit("inspect-host-node", null);
 	}, []);
+	const viewSource = useCallback(() => {
+		if (selected) {
+			emit("view-source", selected.id);
+		}
+	}, [selected]);
+
+	const canViewSource =
+		selected &&
+		selected.type !== DevNodeType.Group &&
+		selected.type !== DevNodeType.Element;
 
 	return (
 		<Actions class={s.actions}>
 			<ComponentName>{selected && selected.name}</ComponentName>
 			<div class={s.iconActions}>
 				{selected && (
-					<IconBtn title="Show matching DOM element" onClick={inspectHostNode}>
-						<InspectNativeIcon />
-					</IconBtn>
-				)}
-				{selected && (
-					<IconBtn title="Log internal vnode" onClick={log}>
-						<BugIcon />
-					</IconBtn>
+					<Fragment>
+						<IconBtn
+							title="Show matching DOM element"
+							onClick={inspectHostNode}
+						>
+							<InspectNativeIcon />
+						</IconBtn>
+						<IconBtn title="Log internal vnode" onClick={log}>
+							<BugIcon />
+						</IconBtn>
+						<IconBtn
+							title="View Component Source"
+							onClick={viewSource}
+							disabled={!canViewSource}
+						>
+							<CodeIcon />
+						</IconBtn>
+					</Fragment>
 				)}
 			</div>
 		</Actions>
diff --git a/src/view/components/sidebar/SidebarActions.tsx b/src/view/components/sidebar/SidebarActions.tsx
index c1f45bd..0dcfe52 100644
--- a/src/view/components/sidebar/SidebarActions.tsx
+++ b/src/view/components/sidebar/SidebarActions.tsx
@@ -2,10 +2,11 @@ import { h } from "preact";
 import s from "./Sidebar.css";
 import { Actions } from "../Actions";
 import { IconBtn } from "../IconBtn";
-import { Refresh, BugIcon, InspectNativeIcon } from "../icons";
+import { Refresh, BugIcon, InspectNativeIcon, CodeIcon } from "../icons";
 import { useStore, useEmitter, useObserver } from "../../store/react-bindings";
 import { useCallback } from "preact/hooks";
 import { ComponentName } from "../ComponentName";
+import { DevNodeType } from "../../store/types";
 
 export function SidebarActions() {
 	const store = useStore();
@@ -22,6 +23,16 @@ export function SidebarActions() {
 	const inspectHostNode = useCallback(() => {
 		emit("inspect-host-node", null);
 	}, []);
+	const viewSource = useCallback(() => {
+		if (node) {
+			emit("view-source", node.id);
+		}
+	}, [node]);
+
+	const canViewSource =
+		node &&
+		node.type !== DevNodeType.Group &&
+		node.type !== DevNodeType.Element;
 
 	return (
 		<Actions class={s.actions}>
@@ -43,6 +54,15 @@ export function SidebarActions() {
 						<BugIcon />
 					</IconBtn>
 				)}
+				{node && (
+					<IconBtn
+						title="View Component Source"
+						onClick={viewSource}
+						disabled={!canViewSource}
+					>
+						<CodeIcon />
+					</IconBtn>
+				)}
 			</div>
 		</Actions>
 	);
diff --git a/src/view/store/types.ts b/src/view/store/types.ts
index 81e866e..5664aea 100644
--- a/src/view/store/types.ts
+++ b/src/view/store/types.ts
@@ -11,20 +11,20 @@ import { PropData } from "../components/sidebar/inspect/parseProps";
 export type ID = number;
 
 export enum DevNodeType {
-	FunctionComponent,
-	ClassComponent,
-	Element,
-	ForwardRef,
-	Memo,
-	Context,
-	Consumer,
-	Suspense,
 	/**
 	 * Groups are virtual nodes inserted by the devtools
 	 * to make certain operations easier. They are not
 	 * created by Preact.
 	 */
 	Group,
+	Element,
+	ClassComponent,
+	FunctionComponent,
+	ForwardRef,
+	Memo,
+	Suspense,
+	Context,
+	Consumer,
 }
 
 export interface DevNode {


Test patch:
diff --git a/src/adapter/debug.test.ts b/src/adapter/debug.test.ts
index d978c8d..0d47860 100644
--- a/src/adapter/debug.test.ts
+++ b/src/adapter/debug.test.ts
@@ -1,7 +1,7 @@
 import { expect } from "chai";
 import { fromSnapshot, toStringTable } from "./debug";
 import { MsgTypes } from "./events/events";
-import { Elements } from "./renderer";
+import { DevNodeType } from "../view/store/types";
 
 describe("debug", () => {
 	describe("fromSnapshot", () => {
@@ -26,7 +26,7 @@ describe("debug", () => {
 				root,
 				MsgTypes.ADD_VNODE,
 				2,
-				Elements.HTML_ELEMENT,
+				DevNodeType.Element,
 				1,
 				9999,
 				1,