From fe59fc04bcdebeb3901cf66a2cc408b40b2fab79 Mon Sep 17 00:00:00 2001 From: Chris Westra Date: Fri, 27 Jul 2018 21:13:01 -0400 Subject: [PATCH] Context menu item for inspecting components (#843) * Context menu item for "Inspect Ember Component" * Try to set context menu when inspector loads Relying on the `emberVersion` message as the trigger point for setting the context menu isn't always reliable. * Scroll component tree to selected item When an item is inspected via the context menu we try to put it into view in the component tree. We make an educated guess as to how far down we should scroll based on the item's index and a magic height number * Add specs for inspecting item via context menu --- app/controllers/component-tree.js | 38 ++++++++++++ app/routes/application.js | 10 +++ app/routes/component-tree.js | 33 ++++++++-- app/templates/component-tree.hbs | 2 +- ember_debug/main.js | 3 + ember_debug/object-inspector.js | 4 +- ember_debug/view-debug.js | 24 ++++++++ skeletons/web-extension/background-script.js | 65 ++++++++++++++++---- skeletons/web-extension/manifest.json | 3 +- tests/acceptance/component-tree-test.js | 18 ++++++ 10 files changed, 182 insertions(+), 18 deletions(-) diff --git a/app/controllers/component-tree.js b/app/controllers/component-tree.js index df5dd5bef2..9b98cc5cad 100644 --- a/app/controllers/component-tree.js +++ b/app/controllers/component-tree.js @@ -13,6 +13,10 @@ import { isEmpty } from '@ember/utils'; +import { + schedule +} from '@ember/runloop'; + import ComponentViewItem from 'ember-inspector/models/component-view-item'; /** @@ -71,12 +75,19 @@ const flattenSearchTree = ( export default Controller.extend({ application: controller(), + queryParams: ['pinnedObjectId'], + + /** + * The entry in the component tree corresponding to the pinnedObjectId + * will be selected + */ pinnedObjectId: null, inspectingViews: false, components: true, options: { components: true, }, + viewTreeLoaded: false, /** * Bound to the search field to filter the component list. @@ -138,6 +149,11 @@ export default Controller.extend({ this.set('expandedStateCache', {}); }, + /** + * Expands the component tree so that entry for the given view will + * be shown. Recursively expands the entry's parents up to the root. + * @param {*} objectId The id of the ember view to show + */ expandToNode(objectId) { let node = this.get('filteredArray').find(item => item.get('id') === objectId); if (node) { @@ -145,6 +161,24 @@ export default Controller.extend({ } }, + /** + * This method is basically a trick to get the `{{vertical-collection}}` in the vicinity + * of the item that's been selected. We can't directly scroll to the element but we + * can guess at how far down the list the item is. Then we can manually set the scrollTop + * of the virtual scroll. + */ + scrollTreeToItem(objectId) { + let selectedItemIndex = this.get('displayedList').findIndex(item => item.view.objectId === objectId); + + if (selectedItemIndex) { + const averageItemHeight = 22; + schedule('afterRender', () => { + document.querySelector('.js-component-tree').scrollTop = averageItemHeight * selectedItemIndex; + }); + } + }, + + actions: { previewLayer({ view: { @@ -199,12 +233,16 @@ export default Controller.extend({ if (objectId) { this.set('pinnedObjectId', objectId); this.expandToNode(objectId); + this.scrollTreeToItem(objectId); this.get('port').send('objectInspector:inspectById', { objectId, }); } }, + /** + * Scrolls the main page to put the selected element into view + */ scrollToElement(elementId) { this.get('port').send('view:scrollToElement', { elementId diff --git a/app/routes/application.js b/app/routes/application.js index b4f71e3c20..e801cc1818 100644 --- a/app/routes/application.js +++ b/app/routes/application.js @@ -18,6 +18,7 @@ export default Route.extend({ port.on('objectInspector:updateErrors', this, this.updateErrors); port.on('objectInspector:droppedObject', this, this.droppedObject); port.on('deprecation:count', this, this.setDeprecationCount); + port.on('view:inspectComponent', this, this.inspectComponent); port.send('deprecation:getCount'); }, @@ -28,6 +29,15 @@ export default Route.extend({ port.off('objectInspector:updateErrors', this, this.updateErrors); port.off('objectInspector:droppedObject', this, this.droppedObject); port.off('deprecation:count', this, this.setDeprecationCount); + port.off('view:inspectComponent', this, this.inspectComponent); + }, + + inspectComponent({ viewId }) { + this.transitionTo('component-tree', { + queryParams: { + pinnedObjectId: viewId + } + }); }, updateObject(options) { diff --git a/app/routes/component-tree.js b/app/routes/component-tree.js index 73d590121b..02dd5d43b5 100644 --- a/app/routes/component-tree.js +++ b/app/routes/component-tree.js @@ -1,13 +1,20 @@ import TabRoute from "ember-inspector/routes/tab"; export default TabRoute.extend({ + queryParams: { + pinnedObjectId: { + replace: true + } + }, + setupController() { this._super(...arguments); this.get('port').on('view:viewTree', this, this.setViewTree); this.get('port').on('view:stopInspecting', this, this.stopInspecting); this.get('port').on('view:startInspecting', this, this.startInspecting); this.get('port').on('view:inspectDOMElement', this, this.inspectDOMElement); - this.get('port').on('view:inspectComponent', this, this.inspectComponent); + + this.set('controller.viewTreeLoaded', false); this.get('port').send('view:setOptions', { options: this.get('controller.options') }); this.get('port').send('view:getTree'); }, @@ -17,12 +24,17 @@ export default TabRoute.extend({ this.get('port').off('view:stopInspecting', this, this.stopInspecting); this.get('port').off('view:startInspecting', this, this.startInspecting); this.get('port').off('view:inspectDOMElement', this, this.inspectDOMElement); - this.get('port').off('view:inspectComponent', this, this.inspectComponent); - }, setViewTree(options) { this.set('controller.viewTree', options.tree); + this.set('controller.viewTreeLoaded', true); + + // If we're waiting for view tree to inspect a component + const componentToInspect = this.get('controller.pinnedObjectId'); + if (componentToInspect) { + this.inspectComponent(componentToInspect); + } }, startInspecting() { @@ -33,11 +45,24 @@ export default TabRoute.extend({ this.set('controller.inspectingViews', false); }, - inspectComponent({ viewId }) { + inspectComponent(viewId) { + if (!this.get('controller.viewTreeLoaded')) { + return; + } + this.get('controller').send('inspect', viewId); }, inspectDOMElement({ elementSelector }) { this.get('port.adapter').inspectDOMElement(elementSelector); + }, + + actions: { + queryParamsDidChange(params) { + const { pinnedObjectId } = params; + if (pinnedObjectId) { + this.inspectComponent(pinnedObjectId); + } + } } }); diff --git a/app/templates/component-tree.hbs b/app/templates/component-tree.hbs index 50852bca6c..e8e32e5cfa 100644 --- a/app/templates/component-tree.hbs +++ b/app/templates/component-tree.hbs @@ -1,4 +1,4 @@ -
+
{{#vertical-collection displayedList estimateHeight=20 as |item i|}}
diff --git a/ember_debug/main.js b/ember_debug/main.js index e551bd88b5..a63ad77e67 100644 --- a/ember_debug/main.js +++ b/ember_debug/main.js @@ -66,6 +66,9 @@ const EmberDebug = EmberObject.extend({ this.reset(); this.get('adapter').debug('Ember Inspector Active'); + this.get('adapter').sendMessage({ + type: 'inspectorLoaded' + }); }, destroyContainer() { diff --git a/ember_debug/object-inspector.js b/ember_debug/object-inspector.js index 3c542d2c8a..709b85f0a9 100644 --- a/ember_debug/object-inspector.js +++ b/ember_debug/object-inspector.js @@ -172,7 +172,9 @@ export default EmberObject.extend(PortMixin, { }, inspectById(message) { const obj = this.sentObjects[message.objectId]; - this.sendObject(obj); + if (obj) { + this.sendObject(obj); + } }, inspectByContainerLookup(message) { const container = this.get('namespace.owner'); diff --git a/ember_debug/view-debug.js b/ember_debug/view-debug.js index b1a6ac9168..19ed2644a8 100644 --- a/ember_debug/view-debug.js +++ b/ember_debug/view-debug.js @@ -111,6 +111,9 @@ export default EmberObject.extend(PortMixin, { if (model) { this.get('objectInspector').sendValueToConsole(model); } + }, + contextMenu() { + this.inspectComponentForNode(this.lastClickedElement); } }, @@ -131,6 +134,14 @@ export default EmberObject.extend(PortMixin, { previewDiv.setAttribute('data-label', 'preview-div'); document.body.appendChild(previewDiv); + // Store last clicked element for context menu + this.lastClickedHandler = (event) => { + if (event.button === 2) { + this.lastClickedElement = event.target; + } + }; + window.addEventListener('mousedown', this.lastClickedHandler); + this.resizeHandler = () => { if (this.glimmerTree) { this.hideLayer(); @@ -155,6 +166,18 @@ export default EmberObject.extend(PortMixin, { } }, + inspectComponentForNode(domNode) { + let viewElem = this.findNearestView(domNode); + if (!viewElem) { + this.get('adapter').log('No Ember component found.'); + return; + } + + this.sendMessage('inspectComponent', { + viewId: viewElem.id + }); + }, + updateDurations(durations) { for (let guid in durations) { if (!durations.hasOwnProperty(guid)) { @@ -187,6 +210,7 @@ export default EmberObject.extend(PortMixin, { willDestroy() { this._super(); window.removeEventListener('resize', this.resizeHandler); + window.removeEventListener('mousedown', this.lastClickedHandler); document.body.removeChild(layerDiv); document.body.removeChild(previewDiv); this.get('_lastNodes').clear(); diff --git a/skeletons/web-extension/background-script.js b/skeletons/web-extension/background-script.js index ee22e1aed5..f67651f7ac 100644 --- a/skeletons/web-extension/background-script.js +++ b/skeletons/web-extension/background-script.js @@ -16,6 +16,8 @@ "use strict"; var activeTabs = {}, + activeTabId, + contextMenuAdded = false, emberInspectorChromePorts = {}; /** @@ -33,7 +35,7 @@ * Creates the title for the pageAction for the current ClientApp * @param {Number} tabId - the current tab */ - function setActionTitle(tabId){ + function setActionTitle(tabId) { chrome.pageAction.setTitle({ tabId: tabId, title: generateVersionsTooltip(activeTabs[tabId]) @@ -46,7 +48,7 @@ * is updated to display the ClientApp's information in the tooltip. * @param {Number} tabId - the current tab */ - function updateTabAction(tabId){ + function updateTabAction(tabId) { chrome.storage.sync.get("options", function(data) { if (!data.options || !data.options.showTomster) { return; } chrome.pageAction.show(tabId); @@ -59,11 +61,44 @@ * Typically used to clearout the icon after reload. * @param {Number} tabId - the current tab */ - function hideAction(tabId){ - delete activeTabs[tabId]; + function hideAction(tabId) { + if (!activeTabs[tabId]) { + return; + } + chrome.pageAction.hide(tabId); } + /** + * Update the tab's contextMenu: https://developer.chrome.com/extensions/contextMenus + * Add a menu item called "Inspect Ember Component" that shows info + * about the component in the inspector. + * @param {Boolean} force don't use the activeTabs array to check for an existing context menu + */ + function updateContextMenu(force) { + // Only add context menu item when an Ember app has been detected + var isEmberApp = !!activeTabs[activeTabId] || force; + if (!isEmberApp && contextMenuAdded) { + chrome.contextMenus.remove('inspect-ember-component'); + contextMenuAdded = false; + } + + if (isEmberApp && !contextMenuAdded) { + chrome.contextMenus.create({ + id: 'inspect-ember-component', + title: 'Inspect Ember Component', + contexts: ['all'], + onclick: function() { + chrome.tabs.sendMessage(activeTabId, { + from: 'devtools', + type: 'view:contextMenu' + }); + } + }); + contextMenuAdded = true; + } + } + /** * Listen for a connection request from the EmberInspector. * When the EmberInspector connects to the extension a messageListener @@ -113,10 +148,14 @@ } else if (request && request.type === 'emberVersion') { // set the version info and update title activeTabs[sender.tab.id] = request.versions; + updateTabAction(sender.tab.id); + updateContextMenu(); } else if (request && request.type === 'resetEmberIcon') { // hide the Tomster icon hideAction(sender.tab.id); + } else if (request && request.type === 'inspectorLoaded') { + updateContextMenu(true); } else { // forward the message to EmberInspector var emberInspectorChromePort = emberInspectorChromePorts[sender.tab.id]; @@ -124,18 +163,22 @@ } }); - - /** - * Event listener for when the tab is updated, usually reloaded. - * Check to see if a ClientApp exists for this tab, and reset the icon - * to show the latest data. - * @param {Number} tabId - the current tab + * Keep track of which browser tab is active and update the context menu. */ - chrome.tabs.onUpdated.addListener(function(tabId) { + chrome.tabs.onActivated.addListener(({ tabId }) => { + activeTabId = tabId; if (activeTabs[tabId]) { updateTabAction(tabId); } + updateContextMenu(); + }); + + /** + * Only keep track of active tabs + */ + chrome.tabs.onRemoved.addListener(({ tabId }) => { + delete activeTabs[tabId]; }); }()); diff --git a/skeletons/web-extension/manifest.json b/skeletons/web-extension/manifest.json index 808a84a12f..df54be3703 100644 --- a/skeletons/web-extension/manifest.json +++ b/skeletons/web-extension/manifest.json @@ -13,7 +13,8 @@ "permissions": [ "", - "storage" + "storage", + "contextMenus" ], "content_security_policy": "script-src 'self'; object-src 'self'", diff --git a/tests/acceptance/component-tree-test.js b/tests/acceptance/component-tree-test.js index a4cac1ef8c..ea86e6e7a8 100644 --- a/tests/acceptance/component-tree-test.js +++ b/tests/acceptance/component-tree-test.js @@ -4,6 +4,7 @@ import { findAll, click, triggerEvent, + currentURL, } from '@ember/test-helpers'; import { run } from '@ember/runloop'; import { module, test } from 'qunit'; @@ -300,4 +301,21 @@ module('Component Tab', function(hooks) { assert.equal(messageSent.name, 'objectInspector:inspectById'); assert.equal(messageSent.message.objectId, 'ember392'); }); + + test('Selects a component in the tree in response to a message from the context menu', async function(assert) { + // Go to the component tree and populate it before sending the message from the context menu + let viewTree = defaultViewTree(); + await visit('/component-tree'); + run(() => { + port.trigger('view:viewTree', { tree: viewTree }); + }); + await wait(); + + run(() => { + port.trigger('view:inspectComponent', { viewId: 'ember267' }); + }); + await wait(); + assert.equal(currentURL(), '/component-tree?pinnedObjectId=ember267', 'It pins the element id as a query param'); + assert.dom('.component-tree-item--selected').hasText('todo-item', 'It selects the item in the tree corresponding to the element'); + }); });