diff --git a/shells/dev/target/NativeTypes.vue b/shells/dev/target/NativeTypes.vue index 77674e464..11541db3d 100644 --- a/shells/dev/target/NativeTypes.vue +++ b/shells/dev/target/NativeTypes.vue @@ -12,7 +12,12 @@ <p> <button @click="sendComponent()">Vuex mutation</button> - <button @click="createLargeArray()">Create large array</button> + <button + style="background: red; color: white;" + @click="createLargeArray()" + > + Create large array + </button> </p> <h3>Set</h3> diff --git a/shells/dev/target/Router.vue b/shells/dev/target/Router.vue deleted file mode 100644 index 935822146..000000000 --- a/shells/dev/target/Router.vue +++ /dev/null @@ -1,23 +0,0 @@ -<template> - <div id="router"> - <h1>Router</h1> - <nav> - <router-link :to="{ name: 'page1' }" exact>Page 1</router-link> - <router-link :to="{ name: 'page2', params: { id: 42 } }" exact>Page 2</router-link> - </nav> - <keep-alive> - <router-view /> - </keep-alive> - </div> -</template> - -<style scoped> -a { - margin-right: 6px; -} - -.router-link-active { - font-weight: bold; -} -</style> - diff --git a/shells/dev/target/index.js b/shells/dev/target/index.js index 704eb84f1..71832abba 100644 --- a/shells/dev/target/index.js +++ b/shells/dev/target/index.js @@ -7,8 +7,8 @@ import VuexObject from './VuexObject.vue' import NativeTypes from './NativeTypes.vue' import Events from './Events.vue' import MyClass from './MyClass.js' -import Router from './Router.vue' import router from './router' +import Router from './router/Router.vue' window.VUE_DEVTOOLS_CONFIG = { openInEditorHost: '/' @@ -25,6 +25,12 @@ circular.self = circular new Vue({ store, router, + data: { + obj: { + items: items, + circular + } + }, render (h) { return h('div', null, [ h(Counter), @@ -35,12 +41,6 @@ new Vue({ h(Router, { key: [] }), h(VuexObject) ]) - }, - data: { - obj: { - items: items, - circular - } } }).$mount('#app') diff --git a/shells/dev/target/router.js b/shells/dev/target/router.js index d0a703526..1b37e0d8f 100644 --- a/shells/dev/target/router.js +++ b/shells/dev/target/router.js @@ -1,13 +1,49 @@ import Vue from 'vue' import VueRouter from 'vue-router' -import Page1 from './Page1.vue' -import Page2 from './Page2.vue' +import RouteOne from './router/RouteOne.vue' +import RouteTwo from './router/RouteTwo.vue' +import RouteWithParams from './router/RouteWithParams.vue' +import NamedRoute from './router/NamedRoute.vue' +import RouteWithQuery from './router/RouteWithQuery.vue' +import RouteWithBeforeEnter from './router/RouteWithBeforeEnter.vue' +import RouteWithAlias from './router/RouteWithAlias.vue' +import RouteWithProps from './router/RouteWithProps.vue' +import ParentRoute from './router/ParentRoute.vue' +import ChildRoute from './router/ChildRoute.vue' Vue.use(VueRouter) +const DynamicComponent = { + render: (h) => h('div', 'Hello from dynamic component') +} + const routes = [ - { path: '/', name: 'page1', component: Page1 }, - { path: '/page2', name: 'page2', component: Page2 } + { path: '/route-one', component: RouteOne }, + { path: '/route-two', component: RouteTwo }, + { path: '/route-with-params/:username/:id', component: RouteWithParams }, + { path: '/route-named', component: NamedRoute, name: 'NamedRoute' }, + { path: '/route-with-query', component: RouteWithQuery }, + { path: '/route-with-before-enter', + component: RouteWithBeforeEnter, + beforeEnter: (to, from, next) => { + next() + }}, + { path: '/route-with-redirect', redirect: '/route-one' }, + { path: '/route-with-alias', component: RouteWithAlias, alias: '/this-is-the-alias' }, + { path: '/route-with-dynamic-component', component: DynamicComponent, props: true }, + { path: '/route-with-props', + component: RouteWithProps, + props: { + username: 'My Username', + id: 99 + }}, + { path: '/route-with-props-default', component: RouteWithProps }, + { path: '/route-parent', + component: ParentRoute, + children: [ + { path: '/route-child', component: ChildRoute } + ] + } ] const router = new VueRouter({ diff --git a/shells/dev/target/router/ChildRoute.vue b/shells/dev/target/router/ChildRoute.vue new file mode 100644 index 000000000..3337f2ee4 --- /dev/null +++ b/shells/dev/target/router/ChildRoute.vue @@ -0,0 +1,9 @@ +<template> + <h2>Child route</h2> +</template> + +<script> +export default { + +} +</script> diff --git a/shells/dev/target/router/NamedRoute.vue b/shells/dev/target/router/NamedRoute.vue new file mode 100644 index 000000000..e68fd580b --- /dev/null +++ b/shells/dev/target/router/NamedRoute.vue @@ -0,0 +1,10 @@ +<template> + <div> + <p>Hello named route</p> + </div> +</template> + +<script> +export default { +} +</script> \ No newline at end of file diff --git a/shells/dev/target/router/ParentRoute.vue b/shells/dev/target/router/ParentRoute.vue new file mode 100644 index 000000000..27138e6d6 --- /dev/null +++ b/shells/dev/target/router/ParentRoute.vue @@ -0,0 +1,12 @@ +<template> + <div class="parent"> + <h2>Parent</h2> + <router-view class="child"></router-view> + </div> +</template> + +<script> +export default { + +} +</script> diff --git a/shells/dev/target/router/RouteOne.vue b/shells/dev/target/router/RouteOne.vue new file mode 100644 index 000000000..b131fd159 --- /dev/null +++ b/shells/dev/target/router/RouteOne.vue @@ -0,0 +1,11 @@ +<template> + <div> + <p>Hello from route 1</p> + </div> +</template> + +<script> +export default { + +} +</script> diff --git a/shells/dev/target/router/RouteTwo.vue b/shells/dev/target/router/RouteTwo.vue new file mode 100644 index 000000000..ffa59b0ad --- /dev/null +++ b/shells/dev/target/router/RouteTwo.vue @@ -0,0 +1,11 @@ +<template> + <div> + <p>Hello from route 2</p> + </div> +</template> + +<script> +export default { + +} +</script> \ No newline at end of file diff --git a/shells/dev/target/router/RouteWithAlias.vue b/shells/dev/target/router/RouteWithAlias.vue new file mode 100644 index 000000000..0a67a2ec8 --- /dev/null +++ b/shells/dev/target/router/RouteWithAlias.vue @@ -0,0 +1,10 @@ +<template> + <div> + <p>Hello from route with alias</p> + </div> +</template> + +<script> +export default { +} +</script> \ No newline at end of file diff --git a/shells/dev/target/router/RouteWithBeforeEnter.vue b/shells/dev/target/router/RouteWithBeforeEnter.vue new file mode 100644 index 000000000..479291b0a --- /dev/null +++ b/shells/dev/target/router/RouteWithBeforeEnter.vue @@ -0,0 +1,10 @@ +<template> + <div> + <p>Hello from before enter route</p> + </div> +</template> + +<script> +export default { +} +</script> \ No newline at end of file diff --git a/shells/dev/target/router/RouteWithParams.vue b/shells/dev/target/router/RouteWithParams.vue new file mode 100644 index 000000000..0b8db1849 --- /dev/null +++ b/shells/dev/target/router/RouteWithParams.vue @@ -0,0 +1,11 @@ +<template> + <div> + <p>Hello from route with params: Username: {{ $route.params.username }}, Id: {{ $route.params.id }}</p> + </div> +</template> + +<script> +export default { + +} +</script> \ No newline at end of file diff --git a/shells/dev/target/router/RouteWithProps.vue b/shells/dev/target/router/RouteWithProps.vue new file mode 100644 index 000000000..34e7af10a --- /dev/null +++ b/shells/dev/target/router/RouteWithProps.vue @@ -0,0 +1,20 @@ +<template> + <div> + <p>Hello from route with props: Username: {{ username }}, Id: {{ id }}</p> + </div> +</template> + +<script> +export default { + props: { + username: { + type: String, + default: 'ms' + }, + id: { + type: Number, + default: 33 + } + } +} +</script> \ No newline at end of file diff --git a/shells/dev/target/router/RouteWithQuery.vue b/shells/dev/target/router/RouteWithQuery.vue new file mode 100644 index 000000000..759da6f26 --- /dev/null +++ b/shells/dev/target/router/RouteWithQuery.vue @@ -0,0 +1,10 @@ +<template> + <div> + <p>Hello from route with query: {{ $route.query }}</p> + </div> +</template> + +<script> +export default { +} +</script> \ No newline at end of file diff --git a/shells/dev/target/router/Router.vue b/shells/dev/target/router/Router.vue new file mode 100644 index 000000000..ffdb05fb3 --- /dev/null +++ b/shells/dev/target/router/Router.vue @@ -0,0 +1,35 @@ +<template> + <div> + <p><router-link to="/route-one">Go to Route One</router-link></p> + <p><router-link to="/route-two">Go to Route Two</router-link></p> + <p><router-link to="/route-with-params/markussorg/5">Go to route with params</router-link></p> + <p><router-link to="/route-named">Go to named route</router-link></p> + <p><router-link to="/route-with-query?el=value&test=true">Go to route with query</router-link></p> + <p><router-link to="/route-with-before-enter">Go to route with before enter</router-link></p> + <p><router-link to="/route-with-redirect">Go to route with redirect</router-link></p> + <p><router-link to="/this-is-the-alias">Go to route with alias</router-link></p> + <p><router-link to="/route-with-dynamic-component">Go to route with dyn. component</router-link></p> + <p><router-link to="/route-with-props">Go to route with props</router-link></p> + <p><router-link to="/route-with-props-default">Go to route with props (default)</router-link></p> + <p><router-link to="/route-parent">Go to route parent</router-link></p> + <p><router-link to="/route-child">Go to route child</router-link></p> + <keep-alive> + <router-view /> + </keep-alive> + <p><button @click="addRoutes">Add new routes</button></p> + </div> +</template> + +<script> +import RouteOne from './RouteOne.vue' + +export default { + methods: { + addRoutes () { + this.$router.addRoutes([ + { path: '/new-route', component: RouteOne } + ]) + } + } +} +</script> diff --git a/src/backend/index.js b/src/backend/index.js index 489c94dba..b7ca82eea 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -4,6 +4,7 @@ import { highlight, unHighlight, getInstanceOrVnodeRect } from './highlighter' import { initVuexBackend } from './vuex' import { initEventsBackend } from './events' +import { initRouterBackend } from './router' import { initPerfBackend } from './perf' import { findRelatedComponent } from './utils' import { stringify, classify, camelize, set, parse, getComponentName } from '../util' @@ -121,6 +122,10 @@ function connect (Vue) { }) } + hook.once('router:init', () => { + initRouterBackend(hook.Vue, bridge, rootInstances) + }) + // events initEventsBackend(Vue, bridge) @@ -206,6 +211,7 @@ function scan () { return true } }) + hook.emit('router:init') flush() } @@ -340,7 +346,7 @@ function capture (instance, index, list) { captureCount++ } - if (instance.$options && instance.$options.abstract) { + if (instance.$options && instance.$options.abstract && instance._vnode.componentInstance) { instance = instance._vnode.componentInstance } diff --git a/src/backend/perf.js b/src/backend/perf.js index f52511adb..ec29d0a49 100644 --- a/src/backend/perf.js +++ b/src/backend/perf.js @@ -95,8 +95,10 @@ function applyHooks (vm) { if (renderHook && renderHook.before) { // Render hook ends before one hook const metric = renderMetrics[renderHook.before] - metric.end = time - addComponentMetric(vm.$options, renderHook.before, metric.start, metric.end) + if (metric) { + metric.end = time + addComponentMetric(vm.$options, renderHook.before, metric.start, metric.end) + } } // After diff --git a/src/backend/router.js b/src/backend/router.js index a7a9f1970..a5af0a539 100644 --- a/src/backend/router.js +++ b/src/backend/router.js @@ -1,3 +1,61 @@ +import { stringify } from '../util' + +export function initRouterBackend (Vue, bridge, rootInstances) { + let recording = true + + const getSnapshot = () => { + const routeChanges = [] + rootInstances.forEach(instance => { + const router = instance._router + if (router && router.options && router.options.routes) { + routeChanges.push(...router.options.routes) + } + }) + return stringify({ + routeChanges + }) + } + + bridge.send('routes:init', getSnapshot()) + + bridge.on('router:toggle-recording', enabled => { + recording = enabled + }) + + rootInstances.forEach(instance => { + const router = instance._router + + if (router) { + router.afterEach((to, from) => { + if (!recording) return + bridge.send('router:changed', stringify({ + to, + from, + timestamp: Date.now() + })) + }) + bridge.send('router:init', stringify({ + mode: router.mode, + current: { + from: router.history.current, + to: router.history.current, + timestamp: Date.now() + } + })) + + if (router.matcher && router.matcher.addRoutes) { + const addRoutes = router.matcher.addRoutes + router.matcher.addRoutes = function (routes) { + routes.forEach((item) => { + bridge.send('routes:changed', stringify(item)) + }) + addRoutes.call(this, routes) + } + } + } + }) +} + export function getCustomRouterDetails (router) { return { _custom: { diff --git a/src/devtools/App.vue b/src/devtools/App.vue index 631b1392b..11e3da1a9 100644 --- a/src/devtools/App.vue +++ b/src/devtools/App.vue @@ -76,9 +76,46 @@ value="events" icon-left="grain" class="events-tab flat big-tag" + @focus.native="isRouterGroupOpen = false" > Events </VueGroupButton> + <GroupDropdown + v-tooltip="$t('App.routing.tooltip')" + :is-open="isRouterGroupOpen" + :options="routingTabs" + :value="routeModel" + @update="isRouterGroupOpen = $event" + @select="routeModel = $event" + > + <template slot="header"> + <VueIcon + icon="directions" + style="margin-right: 6px" + /> + <span class="hide-below-wide"> + Routing + </span> + <VueIcon + icon="keyboard_arrow_down" + style="margin-left: 6px" + /> + </template> + <template + slot="option" + slot-scope="{ option }" + > + <VueGroupButton + :value="option.name" + :icon-left="option.icon" + style="width: 100%;" + class="events-tab flat big-tag" + @selected="isRouterGroupOpen = false" + > + {{ option.label }} + </VueGroupButton> + </template> + </GroupDropdown> <VueGroupButton v-tooltip="$t('App.perf.tooltip')" :class="{ @@ -98,6 +135,7 @@ value="settings" icon-left="settings_applications" class="settings-tab flat" + @focus.native="isRouterGroupOpen = false" > Settings </VueGroupButton> @@ -126,8 +164,11 @@ import ComponentsTab from './views/components/ComponentsTab.vue' import EventsTab from './views/events/EventsTab.vue' import VuexTab from './views/vuex/VuexTab.vue' +import RouterTab from './views/router/RouterTab.vue' +import RoutesTab from './views/routes/RoutesTab.vue' import { SPECIAL_TOKENS } from '../util' import Keyboard from './mixins/keyboard' +import GroupDropdown from 'components/GroupDropdown.vue' import { mapState } from 'vuex' @@ -137,7 +178,10 @@ export default { components: { components: ComponentsTab, vuex: VuexTab, - events: EventsTab + events: EventsTab, + router: RouterTab, + routes: RoutesTab, + GroupDropdown }, mixins: [ @@ -161,9 +205,15 @@ export default { this.$router.push({ name: 'events' }) return false } else if (code === 'Digit4') { + if (this.$route.name !== 'router') { + this.$router.push({ name: 'router' }) + } else { + this.$router.push({ name: 'routes' }) + } + } else if (code === 'Digit5') { this.$router.push({ name: 'perf' }) return false - } else if (code === 'Digit5') { + } else if (code === 'Digit6') { this.$router.push({ name: 'settings' }) return false } else if (key === 'p' || code === 'KeyP') { @@ -175,6 +225,16 @@ export default { }) ], + data () { + return { + isRouterGroupOpen: false, + routingTabs: [ + { name: 'router', label: 'History', icon: 'directions' }, + { name: 'routes', label: 'Routes', icon: 'book' } + ] + } + }, + computed: { ...mapState({ message: state => state.message, @@ -326,4 +386,8 @@ export default { .container overflow hidden flex 1 + +.hide-below-wide + @media (max-width: $wide) + display: none </style> diff --git a/src/devtools/components/GroupDropdown.vue b/src/devtools/components/GroupDropdown.vue new file mode 100644 index 000000000..cbd41dd5b --- /dev/null +++ b/src/devtools/components/GroupDropdown.vue @@ -0,0 +1,114 @@ +<template> + <div + class="group-dropdown" + :tabindex="isOpen ? -1 : 0" + :class="{ + 'selected': isValueInOptions + }" + @mouseenter="$emit('update', true)" + @mouseleave="$emit('update', false)" + @focus="$emit('update', true)" + > + <div + class="header" + @click="selectDefault" + > + <slot name="header" /> + </div> + + <div + v-show="isOpen" + class="group-dropdown-options" + > + <template v-for="option of options"> + <slot + :ref="option.name" + name="option" + :option="option" + /> + </template> + </div> + </div> +</template> + +<script> +export default { + props: { + isOpen: { + type: Boolean, + default: false + }, + + options: { + type: Array, + required: true + }, + + value: { + type: String, + required: true + } + }, + + computed: { + isValueInOptions () { + return this.options.find( + option => option.name === this.value + ) + } + }, + + watch: { + isOpen (isOpen) { + if (isOpen) { + window.addEventListener('click', this.outsideClickHandler) + } else { + window.removeEventListener('click', this.outsideClickHandler) + } + } + }, + + methods: { + outsideClickHandler ($event) { + if (!this.$el.contains($event.target)) { + this.$emit('update', false) + } + }, + + selectDefault () { + this.$emit('select', this.options[0].name) + this.$el.blur() + } + } +} +</script> + +<style lang="stylus" scoped> +.group-dropdown + position relative + z-index 100 + &:focus + outline none + .vue-ui-dark-mode & + background darken($vue-ui-color-dark, 8%) + .header + display flex + align-items center + padding 0 14px + height 48px + cursor pointer + & /deep/ svg + fill #2c3e50 + +.group-dropdown-options + position absolute + background white + left 0 + top 48px + width 100% + box-shadow 0 3px 6px rgba(0,0,0,0.15) + border-bottom-left-radius 3px + border-bottom-right-radius 3px + .vue-ui-dark-mode & + background lighten($vue-ui-color-darker, 3%) +</style> diff --git a/src/devtools/components/SplitPane.vue b/src/devtools/components/SplitPane.vue index 5057ddc7e..fc80b37cc 100644 --- a/src/devtools/components/SplitPane.vue +++ b/src/devtools/components/SplitPane.vue @@ -154,5 +154,4 @@ export default { bottom -5px height 10px cursor ns-resize - </style> diff --git a/src/devtools/components/TriplePane.vue b/src/devtools/components/TriplePane.vue new file mode 100644 index 000000000..2102b0677 --- /dev/null +++ b/src/devtools/components/TriplePane.vue @@ -0,0 +1,73 @@ +<template> + <div style="height: 100%;" class="split-pane" + @mousemove="dragMove" + @mouseup="dragEnd" + @mouseleave="dragEnd" + :class="{ dragging: dragging }"> + <div class="left" :style="{ width: widthLeft + '%' }"> + <slot name="left"></slot> + <div class="dragger" @mousedown="dragStartLeft"></div> + </div> + <div class="middle" :style="{ width: widthMiddle + '%' }"> + <slot name="middle"></slot> + <div class="dragger" @mousedown="dragStartRight"></div> + </div> + <div class="right" :style="{ width: widthRight + '%' }"> + <slot name="right"></slot> + </div> + </div> +</template> + +<script> +export default { + data () { + return { + widthLeft: 20, + widthMiddle: 40, + widthRight: 40, + draggingLeft: false, + draggingRight: false + } + }, + computed: { + dragging () { + return this.draggingLeft && this.draggingRight + } + }, + methods: { + dragStartLeft (e) { + this.draggingLeft = true + this.startXLeft = e.pageX + this.startSplitLeft = this.widthLeft + this.startSplitMiddle = this.widthMiddle + }, + dragStartRight (e) { + this.draggingRight = true + this.startXRight = e.pageX + this.startSplitRight = this.widthRight + this.startSplitMiddle = this.widthMiddle + }, + dragMove (e) { + if (this.draggingLeft) { + const diff = this.getDiff(e, this.startXLeft) + this.widthLeft = this.startSplitLeft + diff + this.widthMiddle = this.startSplitMiddle - diff + } + if (this.draggingRight) { + const diff = this.getDiff(e, this.startXRight) + this.widthMiddle = this.startSplitMiddle + diff + this.widthRight = this.startSplitRight - diff + } + }, + getDiff (e, start) { + const dx = e.pageX - start + const totalWidth = this.$el.offsetWidth + return ~~(dx / totalWidth * 100) + }, + dragEnd () { + this.draggingLeft = false + this.draggingRight = false + } + } +} +</script> diff --git a/src/devtools/components/splitPanes.styl b/src/devtools/components/splitPanes.styl new file mode 100644 index 000000000..2a5ba77c5 --- /dev/null +++ b/src/devtools/components/splitPanes.styl @@ -0,0 +1,22 @@ +.split-pane + display flex + height 100% + &.dragging + cursor ew-resize + +.left, .right, .middle + position relative + +.left, .middle + border-right 1px solid $border-color + .app.dark & + border-right 1px solid $dark-border-color + +.dragger + position absolute + z-index 99 + top 0 + bottom 0 + right -5px + width 10px + cursor ew-resize \ No newline at end of file diff --git a/src/devtools/index.js b/src/devtools/index.js index fcef10edb..cdd71b573 100644 --- a/src/devtools/index.js +++ b/src/devtools/index.js @@ -169,6 +169,27 @@ function initApp (shell) { } }) + bridge.on('router:init', payload => { + store.commit('router/INIT', parse(payload)) + }) + + bridge.on('router:changed', payload => { + store.commit('router/CHANGED', parse(payload)) + }) + + bridge.on('routes:init', payload => { + store.commit('routes/INIT', parse(payload)) + }) + + bridge.on('routes:changed', payload => { + store.commit('routes/CHANGED', parse(payload)) + }) + + // register filters + Vue.filter('formatTime', function (timestamp) { + return (new Date(timestamp)).toString().match(/\d\d:\d\d:\d\d/)[0] + }) + bridge.on('events:reset', () => { store.commit('events/RESET') }) diff --git a/src/devtools/locales/en.js b/src/devtools/locales/en.js index 922129717..d211f749a 100644 --- a/src/devtools/locales/en.js +++ b/src/devtools/locales/en.js @@ -9,11 +9,14 @@ export default { refresh: { tooltip: '[[{{keys.ctrl}}]] + [[{{keys.alt}}]] + [[R]] Force Refresh' }, + routing: { + tooltip: '[[{{keys.ctrl}}]] + [[4]] Switch to Routing' + }, perf: { - tooltip: '[[{{keys.ctrl}}]] + [[4]] Switch to Performance' + tooltip: '[[{{keys.ctrl}}]] + [[5]] Switch to Performance' }, settings: { - tooltip: '[[{{keys.ctrl}}]] + [[5]] Switch to Settings' + tooltip: '[[{{keys.ctrl}}]] + [[6]] Switch to Settings' }, vuex: { tooltip: '[[{{keys.ctrl}}]] + [[2]] Switch to Vuex' diff --git a/src/devtools/router.js b/src/devtools/router.js index d93f93503..7b7d24062 100644 --- a/src/devtools/router.js +++ b/src/devtools/router.js @@ -8,6 +8,8 @@ import PerfTab from './views/perf/PerfTab.vue' import ComponentRenderStats from './views/perf/ComponentRenderStats.vue' import FramerateGraph from './views/perf/FramerateGraph.vue' import SettingsTab from './views/settings/SettingsTab.vue' +import RouterTab from './views/router/RouterTab.vue' +import RoutesTab from './views/routes/RoutesTab.vue' Vue.use(VueRouter) @@ -31,6 +33,16 @@ const routes = [ name: 'events', component: EventsTab }, + { + path: '/router', + name: 'router', + component: RouterTab + }, + { + path: '/routes', + name: 'routes', + component: RoutesTab + }, { path: '/perf', component: PerfTab, diff --git a/src/devtools/store/index.js b/src/devtools/store/index.js index 679131dac..a7956b564 100644 --- a/src/devtools/store/index.js +++ b/src/devtools/store/index.js @@ -3,6 +3,8 @@ import Vuex from 'vuex' import components from 'views/components/module' import vuex from 'views/vuex/module' import events from 'views/events/module' +import router from 'views/router/module' +import routes from 'views/routes/module' import perf from 'views/perf/module' Vue.use(Vuex) @@ -27,6 +29,8 @@ const store = new Vuex.Store({ components, vuex, events, + router, + routes, perf } }) @@ -38,6 +42,8 @@ if (module.hot) { 'views/components/module', 'views/vuex/module', 'views/events/module', + 'views/router/module', + 'views/routes/module', 'views/perf/module' ], () => { try { @@ -46,6 +52,8 @@ if (module.hot) { components: require('views/components/module').default, vuex: require('views/vuex/module').default, events: require('views/events/module').default, + router: require('views/router/module').default, + routes: require('views/routes/module').default, perf: require('views/perf/module').default } }) diff --git a/src/devtools/style/variables.styl b/src/devtools/style/variables.styl index 8e22d9bd3..e246a95c2 100644 --- a/src/devtools/style/variables.styl +++ b/src/devtools/style/variables.styl @@ -32,3 +32,47 @@ $dark-border-color = lighten($vue-ui-color-darker, 10%) $dark-background-color = $vue-ui-color-darker $dark-component-color = $active-color $dark-hover-color = $vue-ui-color-dark + +// Entries +// TODO: FIX THIS +// .no-entries +// color: #ccc +// text-align: center +// margin-top: 50px +// line-height: 30px +// +// .entry +// position: relative; +// font-family Menlo, Consolas, monospace +// color #881391 +// cursor pointer +// padding 10px 20px +// font-size 12px +// background-color $background-color +// box-shadow 0 1px 5px rgba(0,0,0,.12) +// .entry-name +// font-weight 600 +// .entry-source +// color #999 +// .component-name +// color $component-color +// .entry-type +// color #999 +// margin-left 8px +// &.active +// color #fff +// background-color $active-color +// .time, .entry-type, .component-name +// color lighten($active-color, 75%) +// .entry-name +// color: #fff +// .entry-source +// color #ddd +// .app.dark & +// background-color $dark-background-color +// +// .time +// font-size 11px +// color #999 +// float right +// margin-top 3px diff --git a/src/devtools/views/perf/FramerateGraph.vue b/src/devtools/views/perf/FramerateGraph.vue index 239ef1206..70a0ffa02 100644 --- a/src/devtools/views/perf/FramerateGraph.vue +++ b/src/devtools/views/perf/FramerateGraph.vue @@ -76,7 +76,8 @@ import FramerateMarkerInspector from './FramerateMarkerInspector.vue' const BUBBLE_COLORS = { mutations: '#FF6B00', - events: '#997fff' + events: '#997fff', + routes: '#42B983' } // In ms diff --git a/src/devtools/views/perf/module.js b/src/devtools/views/perf/module.js index d9f810fa5..51c1f2dc6 100644 --- a/src/devtools/views/perf/module.js +++ b/src/devtools/views/perf/module.js @@ -65,6 +65,15 @@ export default { } })) + const { routeChanges } = rootState.router + addEntries('routes', routeChanges, entry => ({ + label: entry.to.fullPath, + state: { + 'from': entry.from, + 'to': entry.to + } + })) + return markers } }, diff --git a/src/devtools/views/router/RouterHistory.vue b/src/devtools/views/router/RouterHistory.vue new file mode 100644 index 000000000..4a9432f65 --- /dev/null +++ b/src/devtools/views/router/RouterHistory.vue @@ -0,0 +1,190 @@ +<template> + <scroll-pane scroll-event="routes:init"> + <action-header slot="header"> + <div class="search"> + <VueIcon icon="search" /> + <input + ref="filterRoutes" + v-model.trim="filter" + placeholder="Filter routes" + > + </div> + <a + :class="{ disabled: !filteredRoutes.length }" + class="button reset" + @click="reset" + > + <VueIcon + class="small" + icon="do_not_disturb" + /> + <span>Clear</span> + </a> + <a + class="button toggle-recording" + @click="toggleRecording" + > + <VueIcon + :class="{ enabled }" + class="small" + icon="lens" + /> + <span>{{ enabled ? 'Recording' : 'Paused' }}</span> + </a> + </action-header> + <recycle-list + slot="scroll" + :items="filteredRoutes" + :item-height="highDensity ? 22 : 34" + class="history" + :class="{ + 'high-density': highDensity + }" + > + <div + v-if="filteredRoutes.length === 0" + slot="after-container" + class="no-routes" + > + No route transitions found<span v-if="!enabled"><br>(Recording is paused)</span> + </div> + + <template slot-scope="{ item: route, index, active }"> + <div + class="entry list-item" + :class="{ active: inspectedIndex === index }" + :data-active="active" + @click="inspect(filteredRoutes.indexOf(route))" + > + <span class="route-name">{{ route.to.path }}</span> + <span class="time">{{ route.timestamp | formatTime }}</span> + <span + v-if="route.to.redirectedFrom" + class="label redirect" + > + redirect + </span> + <span + v-if="isNotEmpty(route.to.name)" + class="label name" + > + {{ route.to.name }} + </span> + </div> + </template> + </recycle-list> + </scroll-pane> +</template> + +<script> +import { UNDEFINED } from 'src/util' +import ScrollPane from 'components/ScrollPane.vue' +import ActionHeader from 'components/ActionHeader.vue' + +import { mapState, mapMutations, mapGetters } from 'vuex' + +export default { + components: { + ScrollPane, + ActionHeader + }, + computed: { + filter: { + get () { + return this.$store.state.router.filter + }, + set (filter) { + this.$store.commit('router/UPDATE_FILTER', filter) + } + }, + highDensity () { + const pref = this.$shared.displayDensity + return (pref === 'auto' && this.totalCount > 12) || pref === 'high' + }, + ...mapState('router', [ + 'enabled', + 'routeChanges', + 'inspectedIndex' + ]), + ...mapGetters('router', [ + 'filteredRoutes' + ]) + }, + methods: { + ...mapMutations('router', { + inspect: 'INSPECT', + reset: 'RESET', + toggleRecording: 'TOGGLE' + }), + isNotEmpty (value) { + return !!value && value !== UNDEFINED + } + } +} +</script> + +<style lang="stylus" scoped> +.recycle-list + height 100% + +.no-routes + color #ccc + text-align center + margin-top 50px + line-height 30px + +.entry + font-family Menlo, Consolas, monospace + cursor pointer + padding 7px 20px + font-size 12px + line-height: 20px + box-shadow inset 0 1px 0px rgba(0, 0, 0, .08) + min-height 34px + transition padding .15s, min-height .15s + + &::after + content: '' + display table + clear both + &.active + .time + color lighten($active-color, 75%) + .action + color lighten($active-color, 75%) + .vue-ui-icon >>> svg + fill lighten($active-color, 75%) + &:hover + color lighten($active-color, 95%) + .vue-ui-icon >>> svg + fill lighten($active-color, 95%) + .high-density & + padding 4px 20px + span + display inline-block + vertical-align middle + +.route-name + font-weight: 600 + +.time + font-size 11px + color #999 + float right + +.label + float right + font-size 10px + padding 4px 8px + border-radius 6px + margin-right 8px + margin-top: 1px + line-height: 1 + color: #fff + &.name + background-color #aaa + &.alias + background-color #ff8344 + &.redirect + background-color #af90d5 +</style> diff --git a/src/devtools/views/router/RouterMeta.vue b/src/devtools/views/router/RouterMeta.vue new file mode 100644 index 000000000..f0a305990 --- /dev/null +++ b/src/devtools/views/router/RouterMeta.vue @@ -0,0 +1,91 @@ +<template> + <scroll-pane> + <div v-if="activeRouteChange" slot="scroll"> + <state-inspector :state="{ from, to }" /> + </div> + <div v-else slot="scroll" class="no-route-data"> + No route transition selected + </div> + </scroll-pane> +</template> + +<script> +import StateInspector from 'components/StateInspector.vue' +import ActionHeader from 'components/ActionHeader.vue' +import ScrollPane from 'components/ScrollPane.vue' +import { mapGetters } from 'vuex' +import { UNDEFINED } from 'src/util' + +export default { + components: { + ScrollPane, + ActionHeader, + StateInspector + }, + computed: { + ...mapGetters('router', [ + 'activeRouteChange' + ]), + to () { + return this.sanitizeRouteData(this.activeRouteChange.to) + }, + from () { + return this.sanitizeRouteData(this.activeRouteChange.from) + } + }, + methods: { + sanitizeRouteData (routeData) { + const data = { + path: routeData.path, + fullPath: routeData.fullPath + } + if (routeData.redirectedFrom) { + data.redirectedFrom = routeData.redirectedFrom + } + if (!this.isEmptyObject(routeData.params)) { + data.params = routeData.params + } + if (!this.isEmptyObject(routeData.query)) { + data.query = routeData.query + } + if (routeData.name && routeData.name !== UNDEFINED) { + data.name = routeData.name + } + if (routeData.hash && routeData.hash !== '') { + data.hash = routeData.hash + } + if (routeData.meta && !this.isEmptyObject(routeData.meta)) { + data.meta = routeData.meta + } + if (routeData.matched && routeData.matched.length > 0) { + data.matched = this.sanitizeMatched(routeData.matched) + } + return data + }, + isEmptyObject (obj) { + return Object.keys(obj).length === 0 + }, + sanitizeMatched (matched) { + const result = [] + for (let i = 0; i < matched.length; i++) { + const obj = { + path: matched[i].path + } + if (matched[i].props && !this.isEmptyObject(matched[i].props)) { + obj.props = matched[i].props + } + result.push(obj) + } + return result + } + } +} +</script> + +<style lang="stylus" scoped> +.no-route-data + color: #ccc + text-align: center + margin-top: 50px + line-height: 30px +</style> diff --git a/src/devtools/views/router/RouterTab.vue b/src/devtools/views/router/RouterTab.vue new file mode 100644 index 000000000..6e5f71ac7 --- /dev/null +++ b/src/devtools/views/router/RouterTab.vue @@ -0,0 +1,31 @@ +<template> + <div> + <split-pane v-if="hasRouter"> + <router-history slot="left"></router-history> + <router-meta slot="right"></router-meta> + </split-pane> + <div v-else class="notice"> + <div> + No router detected. + </div> + </div> + </div> +</template> + +<script> +import SplitPane from 'components/SplitPane.vue' +import RouterHistory from './RouterHistory.vue' +import RouterMeta from './RouterMeta.vue' +import { mapState } from 'vuex' + +export default { + computed: mapState('router', { + hasRouter: state => state.hasRouter + }), + components: { + SplitPane, + RouterHistory, + RouterMeta + } +} +</script> diff --git a/src/devtools/views/router/module.js b/src/devtools/views/router/module.js new file mode 100644 index 000000000..fb34265c2 --- /dev/null +++ b/src/devtools/views/router/module.js @@ -0,0 +1,61 @@ +import storage from '../../storage' + +const ENABLED_KEY = 'EVENTS_ENABLED' +const enabled = storage.get(ENABLED_KEY) + +const state = { + enabled: enabled == null ? true : enabled, + hasRouter: false, + instances: [], + routeChanges: [], + inspectedIndex: -1, + filter: '' +} + +const mutations = { + 'INIT' (state, payload) { + state.instances = [] + state.routeChanges = [payload.current] + state.inspectedIndex = -1 + state.hasRouter = true + state.instances.push(payload) + }, + 'RESET' (state) { + state.routeChanges = [] + state.inspectedIndex = -1 + }, + 'CHANGED' (state, payload) { + state.routeChanges.push(payload) + if (!state.filter) { + state.inspectedIndex = state.routeChanges.length - 1 + } + }, + 'INSPECT' (state, index) { + state.inspectedIndex = index + }, + 'UPDATE_FILTER' (state, filter) { + state.filter = filter + }, + 'TOGGLE' (state) { + storage.set(ENABLED_KEY, state.enabled = !state.enabled) + bridge.send('router:toggle-recording', state.enabled) + } +} + +const getters = { + activeRouteChange: state => { + return state.routeChanges[state.inspectedIndex] + }, + filteredRoutes: state => { + return state.routeChanges.filter(routeChange => { + return routeChange.from.fullPath.indexOf(state.filter) > -1 || routeChange.to.fullPath.indexOf(state.filter) > -1 + }) + } +} + +export default { + namespaced: true, + state, + mutations, + getters +} diff --git a/src/devtools/views/routes/RouteMeta.vue b/src/devtools/views/routes/RouteMeta.vue new file mode 100644 index 000000000..b9b35fca1 --- /dev/null +++ b/src/devtools/views/routes/RouteMeta.vue @@ -0,0 +1,98 @@ +<template> + <scroll-pane> + <div + v-if="activeRouteChange" + slot="scroll" + > + <state-inspector :state="{ from, to }" /> + </div> + <div + v-else + slot="scroll" + class="no-route-data" + > + No route transition selected + </div> + </scroll-pane> +</template> + +<script> +import StateInspector from 'components/StateInspector.vue' +import ActionHeader from 'components/ActionHeader.vue' +import ScrollPane from 'components/ScrollPane.vue' +import { mapGetters } from 'vuex' +import { UNDEFINED } from 'src/util' + +export default { + components: { + ScrollPane, + ActionHeader, + StateInspector + }, + computed: { + ...mapGetters('router', [ + 'activeRouteChange' + ]), + to () { + return this.sanitizeRouteData(this.activeRouteChange.to) + }, + from () { + return this.sanitizeRouteData(this.activeRouteChange.from) + } + }, + methods: { + sanitizeRouteData (routeData) { + const data = { + path: routeData.path, + fullPath: routeData.fullPath + } + if (routeData.redirectedFrom) { + data.redirectedFrom = routeData.redirectedFrom + } + if (!this.isEmptyObject(routeData.params)) { + data.params = routeData.params + } + if (!this.isEmptyObject(routeData.query)) { + data.query = routeData.query + } + if (routeData.name && routeData.name !== UNDEFINED) { + data.name = routeData.name + } + if (routeData.hash && routeData.hash !== '') { + data.hash = routeData.hash + } + if (routeData.meta && !this.isEmptyObject(routeData.meta)) { + data.meta = routeData.meta + } + if (routeData.matched && routeData.matched.length > 0) { + data.matched = this.sanitizeMatched(routeData.matched) + } + return data + }, + isEmptyObject (obj) { + return Object.keys(obj).length === 0 + }, + sanitizeMatched (matched) { + const result = [] + for (let i = 0; i < matched.length; i++) { + const obj = { + path: matched[i].path + } + if (matched[i].props && !this.isEmptyObject(matched[i].props)) { + obj.props = matched[i].props + } + result.push(obj) + } + return result + } + } +} +</script> + +<style lang="stylus" scoped> +.no-route-data + color: #ccc + text-align: center + margin-top: 50px + line-height: 30px +</style> diff --git a/src/devtools/views/routes/RouterTab.vue b/src/devtools/views/routes/RouterTab.vue new file mode 100644 index 000000000..a7aad9ada --- /dev/null +++ b/src/devtools/views/routes/RouterTab.vue @@ -0,0 +1,39 @@ +<template> + <div> + <triple-pane v-if="hasRouter"> + <routes-tree slot="left" /> + <routes-history slot="middle" /> + <routes-meta slot="right" /> + </triple-pane> + <div + v-else + class="notice" + > + <div> + No routes detected. + </div> + </div> + </div> +</template> + +<script> +import SplitPane from 'components/SplitPane.vue' +import TriplePane from 'components/TriplePane.vue' +import RoutesHistory from './RoutesHistory.vue' +import RoutesTree from './RoutesTree.vue' +import RoutesMeta from './RoutesMeta.vue' +import { mapState } from 'vuex' + +export default { + components: { + SplitPane, + TriplePane, + RoutesHistory, + RoutesMeta, + RoutesTree + }, + computed: mapState('routes', { + hasRouter: state => state.hasRouter + }) +} +</script> diff --git a/src/devtools/views/routes/RoutesHistory.vue b/src/devtools/views/routes/RoutesHistory.vue new file mode 100644 index 000000000..4a9432f65 --- /dev/null +++ b/src/devtools/views/routes/RoutesHistory.vue @@ -0,0 +1,190 @@ +<template> + <scroll-pane scroll-event="routes:init"> + <action-header slot="header"> + <div class="search"> + <VueIcon icon="search" /> + <input + ref="filterRoutes" + v-model.trim="filter" + placeholder="Filter routes" + > + </div> + <a + :class="{ disabled: !filteredRoutes.length }" + class="button reset" + @click="reset" + > + <VueIcon + class="small" + icon="do_not_disturb" + /> + <span>Clear</span> + </a> + <a + class="button toggle-recording" + @click="toggleRecording" + > + <VueIcon + :class="{ enabled }" + class="small" + icon="lens" + /> + <span>{{ enabled ? 'Recording' : 'Paused' }}</span> + </a> + </action-header> + <recycle-list + slot="scroll" + :items="filteredRoutes" + :item-height="highDensity ? 22 : 34" + class="history" + :class="{ + 'high-density': highDensity + }" + > + <div + v-if="filteredRoutes.length === 0" + slot="after-container" + class="no-routes" + > + No route transitions found<span v-if="!enabled"><br>(Recording is paused)</span> + </div> + + <template slot-scope="{ item: route, index, active }"> + <div + class="entry list-item" + :class="{ active: inspectedIndex === index }" + :data-active="active" + @click="inspect(filteredRoutes.indexOf(route))" + > + <span class="route-name">{{ route.to.path }}</span> + <span class="time">{{ route.timestamp | formatTime }}</span> + <span + v-if="route.to.redirectedFrom" + class="label redirect" + > + redirect + </span> + <span + v-if="isNotEmpty(route.to.name)" + class="label name" + > + {{ route.to.name }} + </span> + </div> + </template> + </recycle-list> + </scroll-pane> +</template> + +<script> +import { UNDEFINED } from 'src/util' +import ScrollPane from 'components/ScrollPane.vue' +import ActionHeader from 'components/ActionHeader.vue' + +import { mapState, mapMutations, mapGetters } from 'vuex' + +export default { + components: { + ScrollPane, + ActionHeader + }, + computed: { + filter: { + get () { + return this.$store.state.router.filter + }, + set (filter) { + this.$store.commit('router/UPDATE_FILTER', filter) + } + }, + highDensity () { + const pref = this.$shared.displayDensity + return (pref === 'auto' && this.totalCount > 12) || pref === 'high' + }, + ...mapState('router', [ + 'enabled', + 'routeChanges', + 'inspectedIndex' + ]), + ...mapGetters('router', [ + 'filteredRoutes' + ]) + }, + methods: { + ...mapMutations('router', { + inspect: 'INSPECT', + reset: 'RESET', + toggleRecording: 'TOGGLE' + }), + isNotEmpty (value) { + return !!value && value !== UNDEFINED + } + } +} +</script> + +<style lang="stylus" scoped> +.recycle-list + height 100% + +.no-routes + color #ccc + text-align center + margin-top 50px + line-height 30px + +.entry + font-family Menlo, Consolas, monospace + cursor pointer + padding 7px 20px + font-size 12px + line-height: 20px + box-shadow inset 0 1px 0px rgba(0, 0, 0, .08) + min-height 34px + transition padding .15s, min-height .15s + + &::after + content: '' + display table + clear both + &.active + .time + color lighten($active-color, 75%) + .action + color lighten($active-color, 75%) + .vue-ui-icon >>> svg + fill lighten($active-color, 75%) + &:hover + color lighten($active-color, 95%) + .vue-ui-icon >>> svg + fill lighten($active-color, 95%) + .high-density & + padding 4px 20px + span + display inline-block + vertical-align middle + +.route-name + font-weight: 600 + +.time + font-size 11px + color #999 + float right + +.label + float right + font-size 10px + padding 4px 8px + border-radius 6px + margin-right 8px + margin-top: 1px + line-height: 1 + color: #fff + &.name + background-color #aaa + &.alias + background-color #ff8344 + &.redirect + background-color #af90d5 +</style> diff --git a/src/devtools/views/routes/RoutesMeta.vue b/src/devtools/views/routes/RoutesMeta.vue new file mode 100644 index 000000000..92b7ef120 --- /dev/null +++ b/src/devtools/views/routes/RoutesMeta.vue @@ -0,0 +1,100 @@ +<template> + <scroll-pane> + <div + v-if="activeRouteChange" + slot="scroll" + > + <state-inspector :state="{ options }" /> + </div> + <div + v-else + slot="scroll" + class="no-route-data" + > + No route selected + </div> + </scroll-pane> +</template> + +<script> +import StateInspector from 'components/StateInspector.vue' +import ActionHeader from 'components/ActionHeader.vue' +import ScrollPane from 'components/ScrollPane.vue' +import { mapGetters } from 'vuex' +import { UNDEFINED } from 'src/util' + +export default { + components: { + ScrollPane, + ActionHeader, + StateInspector + }, + computed: { + ...mapGetters('routes', [ + 'activeRouteChange' + ]), + options () { + return this.sanitizeRouteData(this.activeRouteChange) + }, + to () { + return this.sanitizeRouteData(this.activeRouteChange.to) + }, + from () { + return this.sanitizeRouteData(this.activeRouteChange.from) + } + }, + methods: { + sanitizeRouteData (routeData) { + console.log(routeData) + const data = { + path: routeData.path + } + if (routeData.redirect) { + data.redirect = routeData.redirect + } + if (routeData.alias) { + data.alias = routeData.alias + } + if (!this.isEmptyObject(routeData.props)) { + data.props = routeData.props + } + if (routeData.name && routeData.name !== UNDEFINED) { + data.name = routeData.name + } + if (routeData.component) { + const component = {} + // if (routeData.component.__file) { + // component.file = routeData.component.__file + // } + if (routeData.component.template) { + component.template = routeData.component.template + } + if (routeData.component.props) { + component.props = routeData.component.props + } + if (!this.isEmptyObject(component)) { + data.component = component + } + } + if (routeData.children) { + data.children = [] + routeData.children.forEach((item) => { + data.children.push(this.sanitizeRouteData(item)) + }) + } + return data + }, + isEmptyObject (obj) { + return obj === UNDEFINED || !obj || Object.keys(obj).length === 0 + } + } +} +</script> + +<style lang="stylus" scoped> +.no-route-data + color: #ccc + text-align: center + margin-top: 50px + line-height: 30px +</style> diff --git a/src/devtools/views/routes/RoutesTab.vue b/src/devtools/views/routes/RoutesTab.vue new file mode 100644 index 000000000..b4beb9fe5 --- /dev/null +++ b/src/devtools/views/routes/RoutesTab.vue @@ -0,0 +1,31 @@ +<template> + <div> + <split-pane v-if="hasRouter"> + <routes-tree slot="left"></routes-tree> + <routes-meta slot="right"></routes-meta> + </split-pane> + <div v-else class="notice"> + <div> + No routes detected. + </div> + </div> + </div> +</template> + +<script> +import SplitPane from 'components/SplitPane.vue' +import RoutesTree from './RoutesTree.vue' +import RoutesMeta from './RoutesMeta.vue' +import { mapState } from 'vuex' + +export default { + computed: mapState('routes', { + hasRouter: state => state.hasRouter + }), + components: { + SplitPane, + RoutesMeta, + RoutesTree + } +} +</script> diff --git a/src/devtools/views/routes/RoutesTree.vue b/src/devtools/views/routes/RoutesTree.vue new file mode 100644 index 000000000..061cb7907 --- /dev/null +++ b/src/devtools/views/routes/RoutesTree.vue @@ -0,0 +1,60 @@ +<template> + <scroll-pane scroll-event="routes:init"> + <action-header slot="header"> + <div class="search"> + <VueIcon icon="search" /> + <input + ref="filterRoutes" + v-model.trim="filter" + placeholder="Filter routes" + > + </div> + </action-header> + <div slot="scroll" class="tree"> + <routes-tree-item + v-for="(route, index) in filteredRoutes" + ref="instances" + :key="route.path" + :route="route" + :routeId="index" + :depth="0"> + </routes-tree-item> + </div> + </scroll-pane> +</template> + +<script> +import ScrollPane from 'components/ScrollPane.vue' +import ActionHeader from 'components/ActionHeader.vue' +import RoutesTreeItem from './RoutesTreeItem.vue' + +import { mapGetters } from 'vuex' + +export default { + components: { + ScrollPane, + ActionHeader, + RoutesTreeItem + }, + computed: { + filter: { + get () { + return this.$store.state.routes.filter + }, + set (filter) { + this.$store.commit('routes/UPDATE_FILTER', filter) + } + }, + ...mapGetters('routes', [ + 'filteredRoutes' + ]) + } +} +</script> + +<style lang="stylus" scoped> +.route-heading + padding: 0px 10px +.tree + padding 5px +</style> diff --git a/src/devtools/views/routes/RoutesTreeItem.vue b/src/devtools/views/routes/RoutesTreeItem.vue new file mode 100644 index 000000000..1996149ab --- /dev/null +++ b/src/devtools/views/routes/RoutesTreeItem.vue @@ -0,0 +1,186 @@ +<template> + <div + class="instance" + :class="{ selected: selected }" + > + <div + class="self" + :class="{ selected: selected }" + :style="{ paddingLeft: depth * 15 + 'px' }" + @click.stop="inspect(routeId)" + @dblclick="toggleExpand" + > + <span class="content"> + <!-- arrow wrapper for better hit box --> + <span + v-if="route.children && route.children.length" + class="arrow-wrapper" + @click="toggleExpand" + > + <span + class="arrow right" + :class="{ rotated: expanded }" + /> + </span> + <span class="instance-name"> + {{ route.path }} + </span> + </span> + <span + v-if="route.name" + class="info name" + > + {{ route.name }} + </span> + <span + v-if="route.alias" + class="info alias" + > + alias: <b>{{ route.alias }}</b> + </span> + <span + v-if="route.redirect" + class="info redirect" + > + redirect: <b>{{ route.redirect }}</b> + </span> + <span + v-if="isActive" + class="info active" + > + active + </span> + </div> + <div v-if="expanded"> + <routes-tree-item + v-for="(child, key) in route.children" + :key="child.path" + :route="child" + :route-id="routeId + '_' + key" + :depth="depth + 1" + /> + </div> + </div> + +</template> + +<script> +import { mapState, mapMutations, mapGetters } from 'vuex' + +export default { + name: 'RoutesTreeItem', + props: { + routeId: { + type: [String, Number], + required: true + }, + route: { + type: Object, + required: true + }, + depth: { + type: Number, + required: true + } + }, + data () { + return { + expanded: false + } + }, + computed: { + ...mapState('routes', [ + 'inspectedIndex' + ]), + ...mapGetters('routes', [ + 'activeRoute' + ]), + selected () { + return this.inspectedIndex === this.routeId + }, + isActive () { + return this.activeRoute && this.activeRoute.path === this.route.path + } + }, + methods: { + ...mapMutations('routes', { + inspect: 'INSPECT' + }), + toggleExpand () { + this.expanded = !this.expanded + } + } +} +</script> + +<style lang="stylus" scoped> +.instance + font-family Menlo, Consolas, monospace + +.self + cursor pointer + position relative + overflow hidden + z-index 2 + background-color $background-color + transition background-color .1s ease + border-radius 3px + font-size 14px + line-height 22px + height 22px + white-space nowrap + &.selected + background-color $active-color + .arrow + border-left-color #fff + .instance-name + color #fff + +.arrow + position absolute + top 5px + left 4px + transition transform .1s ease, border-left-color .1s ease + &.rotated + transform rotate(90deg) + +.arrow-wrapper + position absolute + display inline-block + width 16px + height 16px + top 0 + left 4px + +.children + position relative + z-index 1 + +.content + position relative + padding-left 22px + +.instance-name + color $component-color + margin 0 1px + transition color .1s ease + +.info + color #fff + font-size 10px + padding 3px 5px 2px + display inline-block + line-height 10px + border-radius 3px + position relative + top -1px + margin-left 6px + &.name + background-color #b3cbf7 + &.alias + background-color #ff8344 + &.redirect + background-color #aaa + &.active + background-color: #2c7d59 +</style> diff --git a/src/devtools/views/routes/module.js b/src/devtools/views/routes/module.js new file mode 100644 index 000000000..cb1c1710b --- /dev/null +++ b/src/devtools/views/routes/module.js @@ -0,0 +1,62 @@ +import storage from '../../storage' + +const ENABLED_KEY = 'EVENTS_ENABLED' +const enabled = storage.get(ENABLED_KEY) + +const state = { + enabled: enabled == null ? true : enabled, + hasRouter: false, + routeChanges: [], + inspectedIndex: -1, + filter: '' +} + +const mutations = { + INIT (state, payload) { + state.inspectedIndex = -1 + state.hasRouter = true + state.routeChanges = payload.routeChanges + }, + CHANGED (state, payload) { + state.routeChanges.push(payload) + }, + INSPECT (state, index) { + state.inspectedIndex = index + }, + UPDATE_FILTER (state, filter) { + state.filter = filter + } +} + +const getters = { + activeRouteChange: state => { + if (typeof state.inspectedIndex === 'string') { + const path = state.inspectedIndex.split('_') + let obj = state.routeChanges[parseInt(path[0])] + for (var i = 1, len = path.length; i < len; ++i) { + obj = obj.children[parseInt(path[i])] + } + return obj + } + return state.routeChanges[state.inspectedIndex] + }, + activeRoute: (state, getters, rootState) => { + return state.routeChanges.find( + change => rootState.router.routeChanges.find( + historyChange => historyChange.to.path === change.path + ) + ) + }, + filteredRoutes: state => { + return state.routeChanges.filter(routeChange => { + return routeChange.path.indexOf(state.filter) > -1 + }) + } +} + +export default { + namespaced: true, + state, + mutations, + getters +}