|
import type { DevtoolsApi } from '@vue-devtools/app-backend-api' |
|
import type { App, ComponentState, CustomInspectorNode, CustomInspectorState } from '@vue/devtools-api' |
|
import { setupDevtoolsPlugin } from '@vue/devtools-api' |
|
import { isEmptyObject, target } from '@vue-devtools/shared-utils' |
|
import copy from 'clone-deep' |
|
|
|
let actionId = 0 |
|
|
|
const VUEX_ROOT_PATH = '__vdt_root' |
|
const VUEX_MODULE_PATH_SEPARATOR = '[vdt]' |
|
const VUEX_MODULE_PATH_SEPARATOR_REG = /\[vdt\]/g |
|
|
|
|
|
|
|
|
|
const BLUE_600 = 0x2563EB |
|
const LIME_500 = 0x84CC16 |
|
const CYAN_400 = 0x22D3EE |
|
const ORANGE_400 = 0xFB923C |
|
const WHITE = 0xFFFFFF |
|
const DARK = 0x666666 |
|
|
|
export function setupPlugin(api: DevtoolsApi, app: App, Vue) { |
|
const ROUTER_INSPECTOR_ID = 'vue2-router-inspector' |
|
const ROUTER_CHANGES_LAYER_ID = 'vue2-router-changes' |
|
|
|
const VUEX_INSPECTOR_ID = 'vue2-vuex-inspector' |
|
const VUEX_MUTATIONS_ID = 'vue2-vuex-mutations' |
|
const VUEX_ACTIONS_ID = 'vue2-vuex-actions' |
|
|
|
setupDevtoolsPlugin({ |
|
app, |
|
id: 'org.vuejs.vue2-internal', |
|
label: 'Vue 2', |
|
homepage: 'https://vuejs.org/', |
|
logo: 'https://v2.vuejs.org/images/icons/favicon-96x96.png', |
|
settings: { |
|
legacyActions: { |
|
label: 'Legacy Actions', |
|
description: 'Enable this for Vuex < 3.1.0', |
|
type: 'boolean', |
|
defaultValue: false, |
|
}, |
|
}, |
|
}, (api) => { |
|
const hook = target.__VUE_DEVTOOLS_GLOBAL_HOOK__ |
|
|
|
|
|
if (app.$router) { |
|
const router = app.$router |
|
|
|
|
|
|
|
api.addInspector({ |
|
id: ROUTER_INSPECTOR_ID, |
|
label: 'Routes', |
|
icon: 'book', |
|
treeFilterPlaceholder: 'Search routes', |
|
}) |
|
|
|
api.on.getInspectorTree((payload) => { |
|
if (payload.inspectorId === ROUTER_INSPECTOR_ID) { |
|
if (router.options.routes) { |
|
payload.rootNodes = router.options.routes.map(route => formatRouteNode(router, route, '', payload.filter)).filter(Boolean) |
|
} |
|
else { |
|
console.warn(`[Vue Devtools] No routes found in router`, router.options) |
|
} |
|
} |
|
}) |
|
|
|
api.on.getInspectorState((payload) => { |
|
if (payload.inspectorId === ROUTER_INSPECTOR_ID) { |
|
const route = router.matcher.getRoutes().find(r => getPathId(r) === payload.nodeId) |
|
if (route) { |
|
payload.state = { |
|
options: formatRouteData(route), |
|
} |
|
} |
|
} |
|
}) |
|
|
|
|
|
|
|
api.addTimelineLayer({ |
|
id: ROUTER_CHANGES_LAYER_ID, |
|
label: 'Router Navigations', |
|
color: 0x40A8C4, |
|
}) |
|
|
|
router.afterEach((to, from) => { |
|
api.addTimelineEvent({ |
|
layerId: ROUTER_CHANGES_LAYER_ID, |
|
event: { |
|
time: api.now(), |
|
title: to.path, |
|
data: { |
|
from, |
|
to, |
|
}, |
|
}, |
|
}) |
|
api.sendInspectorTree(ROUTER_INSPECTOR_ID) |
|
}) |
|
} |
|
|
|
|
|
if (app.$store) { |
|
const store = app.$store |
|
|
|
api.addInspector({ |
|
id: VUEX_INSPECTOR_ID, |
|
label: 'Vuex', |
|
icon: 'storage', |
|
treeFilterPlaceholder: 'Filter stores...', |
|
}) |
|
|
|
api.on.getInspectorTree((payload) => { |
|
if (payload.inspectorId === VUEX_INSPECTOR_ID) { |
|
if (payload.filter) { |
|
const nodes = [] |
|
flattenStoreForInspectorTree(nodes, store._modules.root, payload.filter, '') |
|
payload.rootNodes = nodes |
|
} |
|
else { |
|
payload.rootNodes = [ |
|
formatStoreForInspectorTree(store._modules.root, 'Root', ''), |
|
] |
|
} |
|
} |
|
}) |
|
|
|
api.on.getInspectorState((payload) => { |
|
if (payload.inspectorId === VUEX_INSPECTOR_ID) { |
|
const modulePath = payload.nodeId |
|
const { module, getterPath } = getStoreModule(store._modules, modulePath) |
|
if (!module) { |
|
return |
|
} |
|
|
|
|
|
module.context.getters |
|
payload.state = formatStoreForInspectorState( |
|
module, |
|
store._makeLocalGettersCache, |
|
getterPath, |
|
) |
|
} |
|
}) |
|
|
|
api.on.editInspectorState((payload) => { |
|
if (payload.inspectorId === VUEX_INSPECTOR_ID) { |
|
let path = payload.path |
|
if (payload.nodeId !== VUEX_ROOT_PATH) { |
|
path = [ |
|
...payload.nodeId.split(VUEX_MODULE_PATH_SEPARATOR).slice(0, -1), |
|
...path, |
|
] |
|
} |
|
store._committing = true |
|
payload.set(store._vm.$data.$$state, path) |
|
store._committing = false |
|
} |
|
}) |
|
|
|
api.addTimelineLayer({ |
|
id: VUEX_MUTATIONS_ID, |
|
label: 'Vuex Mutations', |
|
color: LIME_500, |
|
}) |
|
|
|
api.addTimelineLayer({ |
|
id: VUEX_ACTIONS_ID, |
|
label: 'Vuex Actions', |
|
color: LIME_500, |
|
}) |
|
|
|
hook.on('vuex:mutation', (mutation, state) => { |
|
api.sendInspectorState(VUEX_INSPECTOR_ID) |
|
|
|
const data: any = {} |
|
|
|
if (mutation.payload) { |
|
data.payload = mutation.payload |
|
} |
|
|
|
data.state = copy(state) |
|
|
|
api.addTimelineEvent({ |
|
layerId: VUEX_MUTATIONS_ID, |
|
event: { |
|
time: api.now(), |
|
title: mutation.type, |
|
data, |
|
}, |
|
}) |
|
}) |
|
|
|
function legacySingleActionSub(action, state) { |
|
const data: any = {} |
|
if (action.payload) { |
|
data.payload = action.payload |
|
} |
|
|
|
data.state = state |
|
|
|
api.addTimelineEvent({ |
|
layerId: VUEX_ACTIONS_ID, |
|
event: { |
|
time: api.now(), |
|
title: action.type, |
|
data, |
|
}, |
|
}) |
|
} |
|
|
|
store.subscribeAction?.(api.getSettings().legacyActions |
|
? legacySingleActionSub |
|
: { |
|
before: (action, state) => { |
|
const data: any = {} |
|
if (action.payload) { |
|
data.payload = action.payload |
|
} |
|
action._id = actionId++ |
|
action._time = api.now() |
|
data.state = state |
|
|
|
api.addTimelineEvent({ |
|
layerId: VUEX_ACTIONS_ID, |
|
event: { |
|
time: action._time, |
|
title: action.type, |
|
groupId: action._id, |
|
subtitle: 'start', |
|
data, |
|
}, |
|
}) |
|
}, |
|
after: (action, state) => { |
|
const data: any = {} |
|
const duration = api.now() - action._time |
|
data.duration = { |
|
_custom: { |
|
type: 'duration', |
|
display: `${duration}ms`, |
|
tooltip: 'Action duration', |
|
value: duration, |
|
}, |
|
} |
|
if (action.payload) { |
|
data.payload = action.payload |
|
} |
|
data.state = state |
|
|
|
api.addTimelineEvent({ |
|
layerId: VUEX_ACTIONS_ID, |
|
event: { |
|
time: api.now(), |
|
title: action.type, |
|
groupId: action._id, |
|
subtitle: 'end', |
|
data, |
|
}, |
|
}) |
|
}, |
|
}, { prepend: true }) |
|
|
|
|
|
api.on.inspectTimelineEvent((payload) => { |
|
if (payload.layerId === VUEX_MUTATIONS_ID) { |
|
const getterKeys = Object.keys(store.getters) |
|
if (getterKeys.length) { |
|
const vm = new Vue({ |
|
data: { |
|
$$state: payload.data.state, |
|
}, |
|
computed: store._vm.$options.computed, |
|
}) |
|
const originalVm = store._vm |
|
store._vm = vm |
|
|
|
const tree = transformPathsToObjectTree(store.getters) |
|
payload.data.getters = copy(tree) |
|
|
|
store._vm = originalVm |
|
vm.$destroy() |
|
} |
|
} |
|
}) |
|
} |
|
}) |
|
} |
|
|
|
function formatRouteNode(router, route, parentPath: string, filter: string): CustomInspectorNode { |
|
const node: CustomInspectorNode = { |
|
id: route.path.startsWith('/') ? route.path : `${parentPath}/${route.path}`, |
|
label: route.path, |
|
children: route.children?.map(child => formatRouteNode(router, child, route.path, filter)).filter(Boolean), |
|
tags: [], |
|
} |
|
|
|
if (filter && !node.id.includes(filter) && !node.children?.length) { |
|
return null |
|
} |
|
|
|
if (route.name != null) { |
|
node.tags.push({ |
|
label: String(route.name), |
|
textColor: 0, |
|
backgroundColor: CYAN_400, |
|
}) |
|
} |
|
|
|
if (route.alias != null) { |
|
node.tags.push({ |
|
label: 'alias', |
|
textColor: 0, |
|
backgroundColor: ORANGE_400, |
|
}) |
|
} |
|
|
|
if (node.id === router.currentRoute.path) { |
|
node.tags.push({ |
|
label: 'active', |
|
textColor: WHITE, |
|
backgroundColor: BLUE_600, |
|
}) |
|
} |
|
|
|
if (route.redirect) { |
|
node.tags.push({ |
|
label: |
|
`redirect: ${ |
|
typeof route.redirect === 'string' ? route.redirect : 'Object'}`, |
|
textColor: WHITE, |
|
backgroundColor: DARK, |
|
}) |
|
} |
|
|
|
return node |
|
} |
|
|
|
function formatRouteData(route) { |
|
const data: Omit<ComponentState, 'type'>[] = [] |
|
|
|
data.push({ key: 'path', value: route.path }) |
|
|
|
if (route.redirect) { |
|
data.push({ key: 'redirect', value: route.redirect }) |
|
} |
|
|
|
if (route.alias) { |
|
data.push({ key: 'alias', value: route.alias }) |
|
} |
|
|
|
if (route.props) { |
|
data.push({ key: 'props', value: route.props }) |
|
} |
|
|
|
if (route.name && route.name != null) { |
|
data.push({ key: 'name', value: route.name }) |
|
} |
|
|
|
if (route.component) { |
|
const component: any = {} |
|
|
|
|
|
|
|
if (route.component.template) { |
|
component.template = route.component.template |
|
} |
|
if (route.component.props) { |
|
component.props = route.component.props |
|
} |
|
if (!isEmptyObject(component)) { |
|
data.push({ key: 'component', value: component }) |
|
} |
|
} |
|
|
|
return data |
|
} |
|
|
|
function getPathId(routeMatcher) { |
|
let path = routeMatcher.path |
|
if (routeMatcher.parent) { |
|
path = getPathId(routeMatcher.parent) + path |
|
} |
|
return path |
|
} |
|
|
|
const TAG_NAMESPACED = { |
|
label: 'namespaced', |
|
textColor: WHITE, |
|
backgroundColor: DARK, |
|
} |
|
|
|
function formatStoreForInspectorTree(module, moduleName: string, path: string): CustomInspectorNode { |
|
return { |
|
id: path || VUEX_ROOT_PATH, |
|
|
|
|
|
|
|
label: moduleName, |
|
tags: module.namespaced ? [TAG_NAMESPACED] : [], |
|
children: Object.keys(module._children ?? {}).map(key => |
|
formatStoreForInspectorTree( |
|
module._children[key], |
|
key, |
|
`${path}${key}${VUEX_MODULE_PATH_SEPARATOR}`, |
|
), |
|
), |
|
} |
|
} |
|
|
|
function flattenStoreForInspectorTree(result: CustomInspectorNode[], module, filter: string, path: string) { |
|
if (path.includes(filter)) { |
|
result.push({ |
|
id: path || VUEX_ROOT_PATH, |
|
label: path.endsWith(VUEX_MODULE_PATH_SEPARATOR) ? path.slice(0, path.length - 1) : path || 'Root', |
|
tags: module.namespaced ? [TAG_NAMESPACED] : [], |
|
}) |
|
} |
|
Object.keys(module._children).forEach((moduleName) => { |
|
flattenStoreForInspectorTree(result, module._children[moduleName], filter, path + moduleName + VUEX_MODULE_PATH_SEPARATOR) |
|
}) |
|
} |
|
|
|
function extractNameFromPath(path: string) { |
|
return path && path !== VUEX_ROOT_PATH ? path.split(VUEX_MODULE_PATH_SEPARATOR).slice(-2, -1)[0] : 'Root' |
|
} |
|
|
|
function formatStoreForInspectorState(module, getters, path): CustomInspectorState { |
|
const storeState: CustomInspectorState = { |
|
state: Object.keys(module.context.state ?? {}).map(key => ({ |
|
key, |
|
editable: true, |
|
value: module.context.state[key], |
|
})), |
|
} |
|
|
|
if (getters) { |
|
const pathWithSlashes = path.replace(VUEX_MODULE_PATH_SEPARATOR_REG, '/') |
|
getters = !module.namespaced || path === VUEX_ROOT_PATH ? module.context.getters : getters[pathWithSlashes] |
|
let gettersKeys = Object.keys(getters) |
|
const shouldPickGetters = !module.namespaced && path !== VUEX_ROOT_PATH |
|
if (shouldPickGetters) { |
|
|
|
const definedGettersKeys = Object.keys(module._rawModule.getters ?? {}) |
|
gettersKeys = gettersKeys.filter(key => definedGettersKeys.includes(key)) |
|
} |
|
if (gettersKeys.length) { |
|
let moduleGetters: Record<string, any> |
|
if (shouldPickGetters) { |
|
|
|
moduleGetters = {} |
|
for (const key of gettersKeys) { |
|
moduleGetters[key] = canThrow(() => getters[key]) |
|
} |
|
} |
|
else { |
|
moduleGetters = getters |
|
} |
|
const tree = transformPathsToObjectTree(moduleGetters) |
|
storeState.getters = Object.keys(tree).map(key => ({ |
|
key: key.endsWith('/') ? extractNameFromPath(key) : key, |
|
editable: false, |
|
value: canThrow(() => tree[key]), |
|
})) |
|
} |
|
} |
|
|
|
return storeState |
|
} |
|
|
|
function transformPathsToObjectTree(getters) { |
|
const result = {} |
|
Object.keys(getters).forEach((key) => { |
|
const path = key.split('/') |
|
if (path.length > 1) { |
|
let target = result |
|
const leafKey = path.pop() |
|
for (const p of path) { |
|
if (!target[p]) { |
|
target[p] = { |
|
_custom: { |
|
value: {}, |
|
display: p, |
|
tooltip: 'Module', |
|
abstract: true, |
|
}, |
|
} |
|
} |
|
target = target[p]._custom.value |
|
} |
|
target[leafKey] = canThrow(() => getters[key]) |
|
} |
|
else { |
|
result[key] = canThrow(() => getters[key]) |
|
} |
|
}) |
|
return result |
|
} |
|
|
|
function getStoreModule(moduleMap, path) { |
|
const names = path.split(VUEX_MODULE_PATH_SEPARATOR).filter(n => n) |
|
return names.reduce( |
|
({ module, getterPath }, moduleName, i) => { |
|
const child = module[moduleName === VUEX_ROOT_PATH ? 'root' : moduleName] |
|
if (!child) { |
|
return null |
|
} |
|
return { |
|
module: i === names.length - 1 ? child : child._children, |
|
getterPath: child._rawModule.namespaced |
|
? getterPath |
|
: getterPath.replace(`${moduleName}${VUEX_MODULE_PATH_SEPARATOR}`, ''), |
|
} |
|
}, |
|
{ |
|
module: path === VUEX_ROOT_PATH ? moduleMap : moduleMap.root._children, |
|
getterPath: path, |
|
}, |
|
) |
|
} |
|
|
|
function canThrow(cb: () => any) { |
|
try { |
|
return cb() |
|
} |
|
catch (e) { |
|
return e |
|
} |
|
} |
|
|