Skip to content
This repository was archived by the owner on Jan 6, 2024. It is now read-only.

Commit 3e730ae

Browse files
feat: add component inspector to support component tree navigable (#200)
--------- Co-authored-by: webfansplz <[email protected]>
1 parent cd9e534 commit 3e730ae

File tree

19 files changed

+265
-81
lines changed

19 files changed

+265
-81
lines changed

packages/client/components/ComponentTreeNode.vue

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script setup lang="ts">
2+
import scrollIntoView from 'scroll-into-view-if-needed'
3+
24
/* eslint-disable @typescript-eslint/consistent-type-imports */
35
import type { ComponentTreeNode } from '~/types'
46
@@ -11,6 +13,23 @@ const props = withDefaults(defineProps<{
1113
1214
const { isSelected, select, isExpanded, toggleExpand } = useComponent(props.data)
1315
const { highlight, unhighlight } = useHighlightComponent(props.data)
16+
17+
const toggleEl = ref<HTMLElement>()
18+
19+
function autoScroll() {
20+
if (isSelected.value && toggleEl.value) {
21+
const el = toggleEl.value
22+
scrollIntoView(el, {
23+
scrollMode: 'if-needed',
24+
block: 'center',
25+
behavior: 'smooth',
26+
inline: 'nearest',
27+
})
28+
}
29+
}
30+
31+
watch(isSelected, () => autoScroll())
32+
watch(toggleEl, () => autoScroll())
1433
</script>
1534

1635
<template>
@@ -24,7 +43,7 @@ const { highlight, unhighlight } = useHighlightComponent(props.data)
2443
@mouseover="highlight"
2544
@mouseleave="unhighlight"
2645
>
27-
<h3 vue-block-title @click="data.hasChildren ? toggleExpand(data.id) : () => {}">
46+
<h3 ref="toggleEl" vue-block-title @click="data.hasChildren ? toggleExpand(data.id) : () => {}">
2847
<VDExpandIcon v-if="data.hasChildren" :value="isExpanded" />
2948
<i v-else inline-block h-6 w-6 />
3049
<span

packages/client/composables/component.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ComponentInternalInstance } from 'vue'
2-
import { InstanceMap, getInstanceDetails, getInstanceName, getInstanceOrVnodeRect, getRootElementsFromComponentInstance } from '~/logic/components'
2+
import { getInstanceName } from '@vite-plugin-vue-devtools/core'
3+
import { InstanceMap, getInstanceDetails, getInstanceOrVnodeRect, getRootElementsFromComponentInstance } from '~/logic/components'
34
import { useDevToolsClient } from '~/logic/client'
45
import type { ComponentTreeNode } from '~/types'
56

@@ -13,23 +14,29 @@ const expandedMap = ref<Record<ComponentTreeNode['id'], boolean>>({
1314

1415
export const selectedComponent = ref<ComponentInternalInstance>()
1516
export const selectedComponentState = shallowRef<Record<string, any>[]>([])
17+
18+
export function selectComponentTreeNode(data: ComponentTreeNode) {
19+
selected.value = data.id
20+
selectedComponentName.value = data.name
21+
// TODO (Refactor): get instance state way
22+
selectedComponentState.value = InstanceMap.get(data.id)
23+
selectedComponentNode.value = data
24+
// selectedComponent.value = instance.instance
25+
// selectedComponentState.value = getInstanceState(instance.instance!)
26+
}
27+
28+
export function setExpanded(id: string, expanded: boolean) {
29+
expandedMap.value[id] = expanded
30+
}
31+
1632
export function useComponent(instance: ComponentTreeNode & { instance?: ComponentInternalInstance }) {
17-
function select(data: ComponentTreeNode) {
18-
selected.value = data.id
19-
selectedComponentName.value = data.name
20-
// TODO (Refactor): get instance state way
21-
selectedComponentState.value = InstanceMap.get(data.id)
22-
selectedComponentNode.value = data
23-
// selectedComponent.value = instance.instance
24-
// selectedComponentState.value = getInstanceState(instance.instance!)
25-
}
2633
function toggleExpand(id: string) {
2734
expandedMap.value[id] = !expandedMap.value[id]
2835
}
2936
const isSelected = computed(() => selected.value === instance.id)
3037
const isExpanded = computed(() => expandedMap.value[instance.id])
3138

32-
return { isSelected, select, isExpanded, toggleExpand }
39+
return { isSelected, select: selectComponentTreeNode, isExpanded, toggleExpand }
3340
}
3441

3542
export function useHighlightComponent(node: ComponentTreeNode): {

packages/client/logic/client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ const client = ref<VueDevtoolsHostClient>({
99
componentInspector: {
1010
highlight: () => {},
1111
unHighlight: () => {},
12-
scrollToComponent: () => {},
12+
scrollToComponent: () => { },
13+
startInspect: () => { },
14+
stopInspect: () => { },
1315
},
1416
rerenderHighlight: {
1517
updateInfo: () => {},

packages/client/logic/components/data.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable @typescript-eslint/indent */
22
import type { ComponentInternalInstance } from 'vue'
3-
import { camelize, getInstanceName, getUniqueComponentId, returnError } from './util'
3+
import { getInstanceName } from '@vite-plugin-vue-devtools/core'
4+
import { camelize, getUniqueComponentId, returnError } from './util'
45

56
const vueBuiltins = [
67
'nextTick',

packages/client/logic/components/filter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ComponentInternalInstance } from 'vue'
2-
import { classify, getInstanceName, kebabize } from './util'
2+
import { getInstanceName } from '@vite-plugin-vue-devtools/core'
3+
import { classify, kebabize } from './util'
34

45
export class ComponentFilter {
56
private filter: string
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
export { ComponentWalker, InstanceMap } from './tree'
22
export { getInstanceState, processSetupState, getInstanceDetails, getSetupStateInfo } from './data'
33
export { getInstanceOrVnodeRect, getRootElementsFromComponentInstance } from './el'
4-
export { getInstanceName } from './util'

packages/client/logic/components/tree.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import type { ComponentInternalInstance, SuspenseBoundary } from 'vue'
2-
import { getInstanceName, getRenderKey, getUniqueComponentId, isBeingDestroyed, isFragment } from './util'
2+
import { getInstanceName } from '@vite-plugin-vue-devtools/core'
3+
import { getRenderKey, getUniqueComponentId, isBeingDestroyed, isFragment } from './util'
34
import { ComponentFilter } from './filter'
45
import { getRootElementsFromComponentInstance } from './el'
56
import { getInstanceState } from './data'
7+
import type { ComponentTreeNode } from '~/types'
68

79
export const InstanceMap = new Map()
10+
export const UidToTreeNodeMap = new Map<number, ComponentTreeNode>()
11+
812
export class ComponentWalker {
913
maxDepth: number
1014
recursively: boolean
@@ -27,7 +31,7 @@ export class ComponentWalker {
2731

2832
getComponentParents(instance: ComponentInternalInstance) {
2933
this.captureIds = new Map()
30-
const parents = []
34+
const parents: ComponentInternalInstance[] = []
3135
this.captureId(instance)
3236
let parent = instance
3337
// eslint-disable-next-line no-cond-assign
@@ -205,6 +209,7 @@ export class ComponentWalker {
205209
// }
206210

207211
InstanceMap.set(treeNode.id, getInstanceState(instance))
212+
UidToTreeNodeMap.set(treeNode.uid, treeNode)
208213
treeNode.instance = instance
209214

210215
return treeNode

packages/client/logic/components/util.ts

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -9,53 +9,6 @@ export function isFragment(instance: ComponentInternalInstance) {
99
return Fragment === instance.subTree?.type
1010
}
1111

12-
/**
13-
* Get the appropriate display name for an instance.
14-
*
15-
* @param {Vue} instance
16-
* @return {String}
17-
*/
18-
export function getInstanceName(instance: any) {
19-
const name = getComponentTypeName(instance.type || {})
20-
if (name)
21-
return name
22-
if (instance.root === instance)
23-
return 'Root'
24-
for (const key in instance.parent?.type?.components) {
25-
if (instance.parent.type.components[key] === instance.type)
26-
return saveComponentName(instance, key)
27-
}
28-
29-
for (const key in instance.appContext?.components) {
30-
if (instance.appContext.components[key] === instance.type)
31-
return saveComponentName(instance, key)
32-
}
33-
34-
const fileName = getComponentFileName(instance.type || {})
35-
if (fileName)
36-
return fileName
37-
38-
return 'Anonymous Component'
39-
}
40-
41-
function saveComponentName(instance: ComponentInternalInstance, key: string) {
42-
return key
43-
}
44-
45-
function getComponentTypeName(options: any) {
46-
return options.name || options._componentTag || options.__vdevtools_guessedName || options.__name
47-
}
48-
49-
export function getComponentFileName(options: any) {
50-
const file = options.__file // injected by vite
51-
// TODO: classify
52-
if (file) {
53-
const filename = options.__file?.match(/\/?([^/]+?)(\.[^/.]+)?$/)?.[1]
54-
return filename ?? file
55-
}
56-
// return classify(basename(file, '.vue'))
57-
}
58-
5912
/**
6013
* Returns a devtools unique id for instance.
6114
* @param {Vue} instance

packages/client/logic/timeline.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { nanoid } from 'nanoid'
2-
import { getComponentFileName } from './components/util'
2+
import { getComponentFileName } from '@vite-plugin-vue-devtools/core'
33
import { useDevToolsClient } from './client'
44

55
interface TimelineLayer {

packages/client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"json-editor-vue": "^0.10.6",
3838
"minimatch": "^9.0.3",
3939
"nanoid": "^4.0.2",
40+
"scroll-into-view-if-needed": "^3.0.10",
4041
"splitpanes": "^3.1.5",
4142
"vanilla-jsoneditor": "^0.17.8",
4243
"vite-hot-client": "^0.2.1",

packages/client/pages/components.vue

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
<script setup lang="ts">
22
import { Pane, Splitpanes } from 'splitpanes'
3-
import { scrollToComponent, selected, selectedComponentName, selectedComponentNode, selectedComponentNodeFilePath } from '../composables/component'
3+
import type { ComponentInternalInstance } from 'vue'
4+
import { scrollToComponent, selectComponentTreeNode, selected, selectedComponentName, selectedComponentNode, selectedComponentNodeFilePath } from '../composables/component'
45
56
/* eslint-disable @typescript-eslint/consistent-type-imports */
7+
import { UidToTreeNodeMap } from '../logic/components/tree'
68
import type { ComponentTreeNode } from '~/types'
79
import { ComponentWalker, getInstanceState } from '~/logic/components'
810
import { useDevToolsClient } from '~/logic/client'
911
import { instance, onVueInstanceUpdate } from '~/logic/app'
1012
import { rootPath } from '~/logic/global'
13+
import { getUniqueComponentId } from '~/logic/components/util'
1114
1215
const componentTree = ref<ComponentTreeNode[]>([])
1316
const filterName = ref('')
@@ -82,14 +85,60 @@ function openInEditor() {
8285
const client = useDevToolsClient()
8386
client.value.openInEditor(selectedComponentNodeFilePath.value)
8487
}
88+
89+
const client = useDevToolsClient()
90+
91+
const inspectorEnabled = ref(false)
92+
93+
function inspectComponentClick(instance: ComponentInternalInstance) {
94+
inspectorEnabled.value = false
95+
const treeNode = UidToTreeNodeMap.get(instance.uid)
96+
if (treeNode) {
97+
selectComponentTreeNode(treeNode)
98+
const walker = new ComponentWalker(0, null, false)
99+
const parents = walker.getComponentParents(instance)
100+
parents.reverse().forEach((instance) => {
101+
const id = getUniqueComponentId(instance)
102+
// Ignore root
103+
if (id.endsWith('root'))
104+
return
105+
setExpanded(id, true)
106+
})
107+
}
108+
}
109+
110+
function toggleInspector(target?: boolean) {
111+
inspectorEnabled.value = target ?? !inspectorEnabled.value
112+
if (inspectorEnabled.value)
113+
client.value.componentInspector.startInspect(inspectComponentClick)
114+
115+
else client.value.componentInspector.stopInspect()
116+
}
117+
118+
const { control, c, escape } = useMagicKeys()
119+
120+
watchEffect(() => {
121+
if ((control.value && c.value) || (escape.value))
122+
toggleInspector(false)
123+
})
85124
</script>
86125

87126
<template>
88127
<div h-screen n-panel-grids>
89128
<Splitpanes>
90129
<Pane border="r base">
91-
<div v-if="componentWalker" w-full px10px py12px>
92-
<VDTextInput v-model="filterName" placeholder="Find components..." />
130+
<div v-if="componentWalker" sticky left-0 top-0 z-300 w-full flex gap2 px10px py12px bg-base>
131+
<VDTextInput v-model="filterName" placeholder="Find components..." flex-1 />
132+
<button p2 @click="() => toggleInspector()">
133+
<svg
134+
xmlns="http://www.w3.org/2000/svg"
135+
style="height: 1.1em; width: 1.1em; opacity:0.5;"
136+
:style="inspectorEnabled ? 'opacity:1;color:#00dc82' : ''"
137+
viewBox="0 0 24 24"
138+
>
139+
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r=".5" fill="currentColor" /><path d="M5 12a7 7 0 1 0 14 0a7 7 0 1 0-14 0m7-9v2m-9 7h2m7 7v2m7-9h2" /></g>
140+
</svg>
141+
</button>
93142
</div>
94143
<div h-screen select-none overflow-scroll p-2 class="no-scrollbar">
95144
<ComponentTreeNode v-for="(item) in componentTree" :key="item.id" :data="item" />

packages/client/pages/rerender-trace.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
<script setup lang="ts">
22
import dayjs from 'dayjs'
3-
import { DevToolsHooks } from '@vite-plugin-vue-devtools/core'
3+
import { DevToolsHooks, getInstanceName } from '@vite-plugin-vue-devtools/core'
44
import type { ComponentInternalInstance, DebuggerEvent, Ref } from 'vue'
55
import { useDevToolsClient } from '~/logic/client'
66
import { rootPath } from '~/logic/global'
77
import { getSetupStateInfo, toRaw } from '~/logic/components/data'
8-
import { getInstanceName } from '~/logic/components'
98
109
type ComponentInstance = ComponentInternalInstance & {
1110
devtoolsRawSetupState: Record<string, unknown>

packages/client/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ComponentInternalInstance } from 'vue'
12
import type { Router } from 'vue-router'
23

34
type WithOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
@@ -27,6 +28,8 @@ export interface VueDevtoolsHostClient {
2728
componentInspector: {
2829
highlight: (_name: string, _bounds: ComponentInspectorBounds) => void
2930
unHighlight: () => void
31+
startInspect(cb?: (instance: ComponentInternalInstance) => void): void
32+
stopInspect(): void
3033
scrollToComponent: (_bounds: ComponentInspectorBounds) => void
3134
}
3235
rerenderHighlight: {

packages/core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
},
4545
"devDependencies": {
4646
"@babel/types": "^7.22.5",
47-
"@vue/compiler-sfc": "^3.3.4"
47+
"@vue/compiler-sfc": "^3.3.4",
48+
"vue": "^3.3.4"
4849
}
4950
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './hook'
22
export * from './host'
33
export * from './rpc'
44
export * from './constant'
5+
export * from './shared'

packages/core/src/shared.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { ComponentInternalInstance } from 'vue'
2+
3+
export function getComponentTypeName(options: any) {
4+
return options.name || options._componentTag || options.__vdevtools_guessedName || options.__name
5+
}
6+
7+
function saveComponentName(instance: ComponentInternalInstance, key: string) {
8+
return key
9+
}
10+
11+
export function getComponentFileName(options: any) {
12+
const file = options.__file // injected by vite
13+
// TODO: classify
14+
if (file) {
15+
const filename = options.__file?.match(/\/?([^/]+?)(\.[^/.]+)?$/)?.[1]
16+
return filename ?? file
17+
}
18+
// return classify(basename(file, '.vue'))
19+
}
20+
21+
/**
22+
* Get the appropriate display name for an instance.
23+
*
24+
* @param {Vue} instance
25+
* @return {String}
26+
*/
27+
export function getInstanceName(instance: any) {
28+
const name = getComponentTypeName(instance.type || {})
29+
if (name)
30+
return name
31+
if (instance.root === instance)
32+
return 'Root'
33+
for (const key in instance.parent?.type?.components) {
34+
if (instance.parent.type.components[key] === instance.type)
35+
return saveComponentName(instance, key)
36+
}
37+
38+
for (const key in instance.appContext?.components) {
39+
if (instance.appContext.components[key] === instance.type)
40+
return saveComponentName(instance, key)
41+
}
42+
43+
const fileName = getComponentFileName(instance.type || {})
44+
if (fileName)
45+
return fileName
46+
47+
return 'Anonymous Component'
48+
}

0 commit comments

Comments
 (0)