diff options
21 files changed, 1803 insertions, 31 deletions
diff --git a/build/common.gypi b/build/common.gypi index d5862d9..ea01182 100644 --- a/build/common.gypi +++ b/build/common.gypi @@ -327,6 +327,7 @@ 'asan%': '<(asan)', 'enable_register_protocol_handler%': '<(enable_register_protocol_handler)', 'enable_smooth_scrolling%': '<(enable_smooth_scrolling)', + 'enable_web_intents%': '<(enable_web_intents)', # Whether to build for Wayland display server 'use_wayland%': 0, @@ -716,6 +717,10 @@ 'grit_defines': ['-D', 'enable_register_protocol_handler'], }], + ['enable_web_intents==1', { + 'grit_defines': ['-D', 'enable_web_intents'], + }], + ['asan==1', { 'clang%': 1, }], @@ -935,6 +940,11 @@ 'ENABLE_REGISTER_PROTOCOL_HANDLER=1', ], }], + ['enable_web_intents==1', { + 'defines': [ + 'ENABLE_INTENTS=1', + ], + }], ], # conditions for 'target_defaults' 'target_conditions': [ ['chromium_code==0', { diff --git a/chrome/app/generated_resources.grd b/chrome/app/generated_resources.grd index daaa708..ec1154b 100644 --- a/chrome/app/generated_resources.grd +++ b/chrome/app/generated_resources.grd @@ -5497,6 +5497,9 @@ Keep your key file in a safe place. You will need it to create new versions of y <message name="IDS_COOKIES_BLOCK_3RDPARTY_CHKBOX" desc="A checkbox in the Content Settings dialog for blocking all 3rd party cookies."> Block third-party cookies from being set </message> + <message name="IDS_INTENTS_TAB_LABEL" desc="Label for Web Intents tab on Content Settings page"> + Web Intents + </message> <!-- Mac users do not close their browser; they quit it. --> <if expr="not is_macosx"> @@ -5649,9 +5652,6 @@ Keep your key file in a safe place. You will need it to create new versions of y <message name="IDS_NOTIFICATIONS_ASK_RADIO" desc="A radio button in Content Settings dialog to configure notifications per-site."> Ask me when a site wants to show desktop notifications (recommended) </message> - <message name="IDS_INTENTS_TAB_LABEL" desc="Label for Intents Windows tab on Content Settings dialog"> - Web Intents - </message> <message name="IDS_INTENTS_HEADER" desc="Header for intent exception management page on Content Settings dialog"> Web Intents Exceptions </message> @@ -5739,6 +5739,17 @@ Keep your key file in a safe place. You will need it to create new versions of y </message> </if> + <if expr="pp_ifdef('use_titlecase')"> + <message name="IDS_INTENTS_MANAGE_BUTTON" desc=""> + Manage Intents... + </message> + </if> + <if expr="not pp_ifdef('use_titlecase')"> + <message name="IDS_INTENTS_MANAGE_BUTTON" desc=""> + Manage intents... + </message> + </if> + <!-- Cookie/Images/JavaScript/Plugins/Pop-ups/Location exception editor dialogs --> <message name="IDS_EXCEPTION_EDITOR_PATTERN_TITLE" desc="Title of the pattern field in the exception editor"> Hostname Pattern: @@ -8179,6 +8190,20 @@ Keep your key file in a safe place. You will need it to create new versions of y Expires: </message> + <!-- Intents Window --> + <message name="IDS_INTENTS_MANAGER_WINDOW_TITLE" desc="The title of the Intents page that lets you manage intent services for websites"> + Intents + </message> + <message name="IDS_INTENTS_DOMAIN_COLUMN_HEADER" desc="Label for the Site column in the intents manager page."> + Site + </message> + <message name="IDS_INTENTS_SERVICE_DATA_COLUMN_HEADER" desc="Label for the Service Data column in the intents manager page."> + Service Data + </message> + <message name="IDS_INTENTS_REMOVE_INTENT_BUTTON" desc="Text on the button to remove an intent."> + Remove Intent + </message> + <!-- Mac users do not close their browser; they quit it. --> <if expr="not is_macosx"> <message name="IDS_COOKIES_COOKIE_EXPIRES_SESSION" desc="The Cookie Expires field value for a session cookie"> diff --git a/chrome/browser/intents/web_intents_registry.h b/chrome/browser/intents/web_intents_registry.h index f5d724a..4b0f646 100644 --- a/chrome/browser/intents/web_intents_registry.h +++ b/chrome/browser/intents/web_intents_registry.h @@ -55,6 +55,7 @@ class WebIntentsRegistry // WebIntentsRegistry. friend class WebIntentsRegistryFactory; friend class WebIntentsRegistryTest; + friend class IntentsModelTest; WebIntentsRegistry(); virtual ~WebIntentsRegistry(); diff --git a/chrome/browser/intents/web_intents_registry_factory.h b/chrome/browser/intents/web_intents_registry_factory.h index 4827097..05ea36b8 100644 --- a/chrome/browser/intents/web_intents_registry_factory.h +++ b/chrome/browser/intents/web_intents_registry_factory.h @@ -18,7 +18,7 @@ class WebIntentsRegistry; class WebIntentsRegistryFactory : public ProfileKeyedServiceFactory { public: // Returns the WebIntentsRegistry that provides intent registration for - // |profile|. + // |profile|. Ownership stays with this factory object. static WebIntentsRegistry* GetForProfile(Profile* profile); // Returns the singleton instance of the WebIntentsRegistryFactory. diff --git a/chrome/browser/resources/options/content_settings.html b/chrome/browser/resources/options/content_settings.html index ea1fb11..9579633 100644 --- a/chrome/browser/resources/options/content_settings.html +++ b/chrome/browser/resources/options/content_settings.html @@ -212,30 +212,34 @@ </div> </section> <!-- Intent registration filter tab contents --> - <section id="intent-filter"> + <if expr="pp_ifdef('enable_web_intents')"> + <section id="intents-section"> <h3 i18n-content="intentsTabLabel" class="content-settings-header"></h3> - <div> - <div class="radio"> - <label> - <input type="radio" name="intents" value="allow"> - <span i18n-content="intentsAllow"></span> - </label> - </div> - <div class="radio"> - <label> - <input type="radio" name="intents" value="ask"> - <span i18n-content="intentsAsk"></span> - </label> - </div> - <div class="radio"> - <label> - <input type="radio" name="intents" value="block"> - <span i18n-content="intentsBlock"></span> - </label> + <div> + <div class="radio"> + <label> + <input type="radio" name="intents" value="allow"> + <span i18n-content="intentsAllow"></span> + </label> + </div> + <div class="radio"> + <label> + <input type="radio" name="intents" value="ask"> + <span i18n-content="intentsAsk"></span> + </label> + </div> + <div class="radio"> + <label> + <input type="radio" name="intents" value="block"> + <span i18n-content="intentsBlock"></span> + </label> + </div> + <button class="exceptions-list-button" contentType="intents" + i18n-content="manage_exceptions"></button> + <button id="manage-intents-button" contentType="intents" + i18n-content="manageIntents"></button> </div> - <button class="exceptions-list-button" contentType="intents" - i18n-content="manage_exceptions"></button> - </div> - </section> + </section> + </if> </div> </div> diff --git a/chrome/browser/resources/options/content_settings.js b/chrome/browser/resources/options/content_settings.js index 08c6015..b5bd521 100644 --- a/chrome/browser/resources/options/content_settings.js +++ b/chrome/browser/resources/options/content_settings.js @@ -45,14 +45,20 @@ cr.define('options', function() { }; } - var manageHandlersButton = - this.pageDiv.querySelector('#manage-handlers-button'); + var manageHandlersButton = $('manage-handlers-button'); if (manageHandlersButton) { manageHandlersButton.onclick = function(event) { OptionsPage.navigateToPage('handlers'); }; } + var manageIntentsButton = $('manage-intents-button'); + if (manageIntentsButton) { + manageIntentsButton.onclick = function(event) { + OptionsPage.navigateToPage('intents'); + }; + } + // Cookies filter page --------------------------------------------------- $('show-cookies-button').onclick = function(event) { chrome.send('coreOptionsUserMetricsAction', ['Options_ShowCookies']); @@ -62,8 +68,8 @@ cr.define('options', function() { if (!templateData.enable_click_to_play) $('click_to_play').hidden = true; - if (!templateData.enable_web_intents) - $('intent-filter').hidden = true; + if (!templateData.enable_web_intents && $('intent-section')) + $('intent-section').hidden = true; }, }; diff --git a/chrome/browser/resources/options/intents_list.js b/chrome/browser/resources/options/intents_list.js new file mode 100644 index 0000000..d4f3fdd --- /dev/null +++ b/chrome/browser/resources/options/intents_list.js @@ -0,0 +1,715 @@ +// Copyright (c) 2011 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// TODO(gbillock): refactor this together with CookiesList once we have +// a better sense from UX design what it'll look like and so what'll be shared. +cr.define('options', function() { + const DeletableItemList = options.DeletableItemList; + const DeletableItem = options.DeletableItem; + const ArrayDataModel = cr.ui.ArrayDataModel; + const ListSingleSelectionModel = cr.ui.ListSingleSelectionModel; + const localStrings = new LocalStrings(); + + /** + * Returns the item's height, like offsetHeight but such that it works better + * when the page is zoomed. See the similar calculation in @{code cr.ui.List}. + * This version also accounts for the animation done in this file. + * @param {Element} item The item to get the height of. + * @return {number} The height of the item, calculated with zooming in mind. + */ + function getItemHeight(item) { + var height = item.style.height; + // Use the fixed animation target height if set, in case the element is + // currently being animated and we'd get an intermediate height below. + if (height && height.substr(-2) == 'px') + return parseInt(height.substr(0, height.length - 2)); + return item.getBoundingClientRect().height; + } + + // Map of parent pathIDs to node objects. + var parentLookup = {}; + + // Pending requests for child information. + var lookupRequests = {}; + + /** + * Creates a new list item for intent service data. Note that these are + * created and destroyed lazily as they scroll into and out of view, + * so they must be stateless. We cache the expanded item in + * @{code IntentsList} though, so it can keep state. + * (Mostly just which item is selected.) + * + * @param {Object} origin Data used to create an intents list item. + * @param {IntentsList} list The list that will contain this item. + * @constructor + * @extends {DeletableItem} + */ + function IntentsListItem(origin, list) { + var listItem = new DeletableItem(null); + listItem.__proto__ = IntentsListItem.prototype; + + listItem.origin = origin; + listItem.list = list; + listItem.decorate(); + + // This hooks up updateOrigin() to the list item, makes the top-level + // tree nodes (i.e., origins) register their IDs in parentLookup, and + // causes them to request their children if they have none. Note that we + // have special logic in the setter for the parent property to make sure + // that we can still garbage collect list items when they scroll out of + // view, even though it appears that we keep a direct reference. + if (origin) { + origin.parent = listItem; + origin.updateOrigin(); + } + + return listItem; + } + + IntentsListItem.prototype = { + __proto__: DeletableItem.prototype, + + /** @inheritDoc */ + decorate: function() { + this.siteChild = this.ownerDocument.createElement('div'); + this.siteChild.className = 'intents-site'; + this.dataChild = this.ownerDocument.createElement('div'); + this.dataChild.className = 'intents-data'; + this.itemsChild = this.ownerDocument.createElement('div'); + this.itemsChild.className = 'intents-items'; + this.infoChild = this.ownerDocument.createElement('div'); + this.infoChild.className = 'intents-details'; + this.infoChild.hidden = true; + var remove = this.ownerDocument.createElement('button'); + remove.textContent = localStrings.getString('removeIntent'); + remove.onclick = this.removeIntent_.bind(this); + this.infoChild.appendChild(remove); + var content = this.contentElement; + content.appendChild(this.siteChild); + content.appendChild(this.dataChild); + content.appendChild(this.itemsChild); + this.itemsChild.appendChild(this.infoChild); + if (this.origin && this.origin.data) { + this.siteChild.textContent = this.origin.data.site; + this.siteChild.setAttribute('title', this.origin.data.site); + } + this.itemList_ = []; + }, + + /** @type {boolean} */ + get expanded() { + return this.expanded_; + }, + set expanded(expanded) { + if (this.expanded_ == expanded) + return; + this.expanded_ = expanded; + if (expanded) { + var oldExpanded = this.list.expandedItem; + this.list.expandedItem = this; + this.updateItems_(); + if (oldExpanded) + oldExpanded.expanded = false; + this.classList.add('show-items'); + this.dataChild.hidden = true; + } else { + if (this.list.expandedItem == this) { + this.list.leadItemHeight = 0; + this.list.expandedItem = null; + } + this.style.height = ''; + this.itemsChild.style.height = ''; + this.classList.remove('show-items'); + this.dataChild.hidden = false; + } + }, + + /** + * The callback for the "remove" button shown when an item is selected. + * Requests that the currently selected intent service be removed. + * @private + */ + removeIntent_: function() { + if (this.selectedIndex_ >= 0) { + var item = this.itemList_[this.selectedIndex_]; + if (item && item.node) + chrome.send('removeIntent', [item.node.pathId]); + } + }, + + /** + * Disable animation within this intents list item, in preparation for + * making changes that will need to be animated. Makes it possible to + * measure the contents without displaying them, to set animation targets. + * @private + */ + disableAnimation_: function() { + this.itemsHeight_ = getItemHeight(this.itemsChild); + this.classList.add('measure-items'); + }, + + /** + * Enable animation after changing the contents of this intents list item. + * See @{code disableAnimation_}. + * @private + */ + enableAnimation_: function() { + if (!this.classList.contains('measure-items')) + this.disableAnimation_(); + this.itemsChild.style.height = ''; + // This will force relayout in order to calculate the new heights. + var itemsHeight = getItemHeight(this.itemsChild); + var fixedHeight = getItemHeight(this) + itemsHeight - this.itemsHeight_; + this.itemsChild.style.height = this.itemsHeight_ + 'px'; + // Force relayout before enabling animation, so that if we have + // changed things since the last layout, they will not be animated + // during subsequent layouts. + this.itemsChild.offsetHeight; + this.classList.remove('measure-items'); + this.itemsChild.style.height = itemsHeight + 'px'; + this.style.height = fixedHeight + 'px'; + if (this.expanded) + this.list.leadItemHeight = fixedHeight; + }, + + /** + * Updates the origin summary to reflect changes in its items. + * Both IntentsListItem and IntentsTreeNode implement this API. + * This implementation scans the descendants to update the text. + */ + updateOrigin: function() { + console.log('IntentsListItem.updateOrigin'); + var text = ''; + for (var i = 0; i < this.origin.children.length; ++i) { + if (text.length > 0) + text += ', ' + this.origin.children[i].data.action; + else + text = this.origin.children[i].data.action; + } + this.dataChild.textContent = text; + + if (this.expanded) + this.updateItems_(); + }, + + /** + * Updates the items section to reflect changes, animating to the new state. + * Removes existing contents and calls @{code IntentsTreeNode.createItems}. + * @private + */ + updateItems_: function() { + this.disableAnimation_(); + this.itemsChild.textContent = ''; + this.infoChild.hidden = true; + this.selectedIndex_ = -1; + this.itemList_ = []; + if (this.origin) + this.origin.createItems(this); + this.itemsChild.appendChild(this.infoChild); + this.enableAnimation_(); + }, + + /** + * Append a new intents node "bubble" to this list item. + * @param {IntentsTreeNode} node The intents node to add a bubble for. + * @param {Element} div The DOM element for the bubble itself. + * @return {number} The index the bubble was added at. + */ + appendItem: function(node, div) { + this.itemList_.push({node: node, div: div}); + this.itemsChild.appendChild(div); + return this.itemList_.length - 1; + }, + + /** + * The currently selected intents node ("intents bubble") index. + * @type {number} + * @private + */ + selectedIndex_: -1, + + /** + * Get the currently selected intents node ("intents bubble") index. + * @type {number} + */ + get selectedIndex() { + return this.selectedIndex_; + }, + + /** + * Set the currently selected intents node ("intents bubble") index to + * @{code itemIndex}, unselecting any previously selected node first. + * @param {number} itemIndex The index to set as the selected index. + * TODO: KILL THIS + */ + set selectedIndex(itemIndex) { + // Get the list index up front before we change anything. + var index = this.list.getIndexOfListItem(this); + // Unselect any previously selected item. + if (this.selectedIndex_ >= 0) { + var item = this.itemList_[this.selectedIndex_]; + if (item && item.div) + item.div.removeAttribute('selected'); + } + // Special case: decrementing -1 wraps around to the end of the list. + if (itemIndex == -2) + itemIndex = this.itemList_.length - 1; + // Check if we're going out of bounds and hide the item details. + if (itemIndex < 0 || itemIndex >= this.itemList_.length) { + this.selectedIndex_ = -1; + this.disableAnimation_(); + this.infoChild.hidden = true; + this.enableAnimation_(); + return; + } + // Set the new selected item and show the item details for it. + this.selectedIndex_ = itemIndex; + this.itemList_[itemIndex].div.setAttribute('selected', ''); + this.disableAnimation_(); + this.infoChild.hidden = false; + this.enableAnimation_(); + // If we're near the bottom of the list this may cause the list item to go + // beyond the end of the visible area. Fix it after the animation is done. + var list = this.list; + window.setTimeout(function() { list.scrollIndexIntoView(index); }, 150); + }, + }; + + /** + * {@code IntentsTreeNode}s mirror the structure of the intents tree lazily, + * and contain all the actual data used to generate the + * {@code IntentsListItem}s. + * @param {Object} data The data object for this node. + * @constructor + */ + function IntentsTreeNode(data) { + this.data = data; + this.children = []; + } + + IntentsTreeNode.prototype = { + /** + * Insert an intents tree node at the given index. + * Both IntentsList and IntentsTreeNode implement this API. + * @param {Object} data The data object for the node to add. + * @param {number} index The index at which to insert the node. + */ + insertAt: function(data, index) { + console.log('IntentsTreeNode.insertAt adding ' + + JSON.stringify(data) + ' at ' + index); + var child = new IntentsTreeNode(data); + this.children.splice(index, 0, child); + child.parent = this; + this.updateOrigin(); + }, + + /** + * Remove an intents tree node from the given index. + * Both IntentsList and IntentsTreeNode implement this API. + * @param {number} index The index of the tree node to remove. + */ + remove: function(index) { + if (index < this.children.length) { + this.children.splice(index, 1); + this.updateOrigin(); + } + }, + + /** + * Clears all children. + * Both IntentsList and IntentsTreeNode implement this API. + * It is used by IntentsList.loadChildren(). + */ + clear: function() { + // We might leave some garbage in parentLookup for removed children. + // But that should be OK because parentLookup is cleared when we + // reload the tree. + this.children = []; + this.updateOrigin(); + }, + + /** + * The counter used by startBatchUpdates() and endBatchUpdates(). + * @type {number} + */ + batchCount_: 0, + + /** + * See cr.ui.List.startBatchUpdates(). + * Both IntentsList (via List) and IntentsTreeNode implement this API. + */ + startBatchUpdates: function() { + this.batchCount_++; + }, + + /** + * See cr.ui.List.endBatchUpdates(). + * Both IntentsList (via List) and IntentsTreeNode implement this API. + */ + endBatchUpdates: function() { + if (!--this.batchCount_) + this.updateOrigin(); + }, + + /** + * Requests updating the origin summary to reflect changes in this item. + * Both IntentsListItem and IntentsTreeNode implement this API. + */ + updateOrigin: function() { + if (!this.batchCount_ && this.parent) + this.parent.updateOrigin(); + }, + + /** + * Create the intents services rows for this node. + * Append the rows to @{code item}. + * @param {IntentsListItem} item The intents list item to create items in. + */ + createItems: function(item) { + if (this.children.length > 0) { + for (var i = 0; i < this.children.length; ++i) + this.children[i].createItems(item); + } else if (this.data && !this.data.hasChildren) { + var div = item.ownerDocument.createElement('div'); + div.className = 'intents-item'; + // Help out screen readers and such: this is a clickable thing. + div.setAttribute('role', 'button'); + + var divAction = item.ownerDocument.createElement('div'); + divAction.className = 'intents-item-action'; + divAction.textContent = this.data.action; + div.appendChild(divAction); + + var divTypes = item.ownerDocument.createElement('div'); + divTypes.className = 'intents-item-types'; + var text = ""; + for (var i = 0; i < this.data.types.length; ++i) { + if (text != "") + text += ", "; + text += this.data.types[i]; + } + divTypes.textContent = text; + div.appendChild(divTypes); + + var divUrl = item.ownerDocument.createElement('div'); + divUrl.className = 'intents-item-url'; + divUrl.textContent = this.data.url; + div.appendChild(divUrl); + + var index = item.appendItem(this, div); + div.onclick = function() { + if (item.selectedIndex == index) + item.selectedIndex = -1; + else + item.selectedIndex = index; + }; + } + }, + + /** + * The parent of this intents tree node. + * @type {?IntentsTreeNode|IntentsListItem} + */ + get parent(parent) { + // See below for an explanation of this special case. + if (typeof this.parent_ == 'number') + return this.list_.getListItemByIndex(this.parent_); + return this.parent_; + }, + set parent(parent) { + if (parent == this.parent) + return; + + if (parent instanceof IntentsListItem) { + // If the parent is to be a IntentsListItem, then we keep the reference + // to it by its containing list and list index, rather than directly. + // This allows the list items to be garbage collected when they scroll + // out of view (except the expanded item, which we cache). This is + // transparent except in the setter and getter, where we handle it. + this.parent_ = parent.listIndex; + this.list_ = parent.list; + parent.addEventListener('listIndexChange', + this.parentIndexChanged_.bind(this)); + } else { + this.parent_ = parent; + } + + + if (parent) + parentLookup[this.pathId] = this; + else + delete parentLookup[this.pathId]; + + if (this.data && this.data.hasChildren && + !this.children.length && !lookupRequests[this.pathId]) { + console.log('SENDING loadIntents'); + lookupRequests[this.pathId] = true; + chrome.send('loadIntents', [this.pathId]); + } + }, + + /** + * Called when the parent is a IntentsListItem whose index has changed. + * See the code above that avoids keeping a direct reference to + * IntentsListItem parents, to allow them to be garbage collected. + * @private + */ + parentIndexChanged_: function(event) { + if (typeof this.parent_ == 'number') { + this.parent_ = event.newValue; + // We set a timeout to update the origin, rather than doing it right + // away, because this callback may occur while the list items are + // being repopulated following a scroll event. Calling updateOrigin() + // immediately could trigger relayout that would reset the scroll + // position within the list, among other things. + window.setTimeout(this.updateOrigin.bind(this), 0); + } + }, + + /** + * The intents tree path id. + * @type {string} + */ + get pathId() { + var parent = this.parent; + if (parent && parent instanceof IntentsTreeNode) + return parent.pathId + ',' + this.data.action; + return this.data.site; + }, + }; + + /** + * Creates a new intents list. + * @param {Object=} opt_propertyBag Optional properties. + * @constructor + * @extends {DeletableItemList} + */ + var IntentsList = cr.ui.define('list'); + + IntentsList.prototype = { + __proto__: DeletableItemList.prototype, + + /** @inheritDoc */ + decorate: function() { + DeletableItemList.prototype.decorate.call(this); + this.classList.add('intents-list'); + this.data_ = []; + this.dataModel = new ArrayDataModel(this.data_); + this.addEventListener('keydown', this.handleKeyLeftRight_.bind(this)); + var sm = new ListSingleSelectionModel(); + sm.addEventListener('change', this.cookieSelectionChange_.bind(this)); + sm.addEventListener('leadIndexChange', this.cookieLeadChange_.bind(this)); + this.selectionModel = sm; + }, + + /** + * Handles key down events and looks for left and right arrows, then + * dispatches to the currently expanded item, if any. + * @param {Event} e The keydown event. + * @private + */ + handleKeyLeftRight_: function(e) { + var id = e.keyIdentifier; + if ((id == 'Left' || id == 'Right') && this.expandedItem) { + var cs = this.ownerDocument.defaultView.getComputedStyle(this); + var rtl = cs.direction == 'rtl'; + if ((!rtl && id == 'Left') || (rtl && id == 'Right')) + this.expandedItem.selectedIndex--; + else + this.expandedItem.selectedIndex++; + this.scrollIndexIntoView(this.expandedItem.listIndex); + // Prevent the page itself from scrolling. + e.preventDefault(); + } + }, + + /** + * Called on selection model selection changes. + * @param {Event} ce The selection change event. + * @private + */ + cookieSelectionChange_: function(ce) { + ce.changes.forEach(function(change) { + var listItem = this.getListItemByIndex(change.index); + if (listItem) { + if (!change.selected) { + // We set a timeout here, rather than setting the item unexpanded + // immediately, so that if another item gets set expanded right + // away, it will be expanded before this item is unexpanded. It + // will notice that, and unexpand this item in sync with its own + // expansion. Later, this callback will end up having no effect. + window.setTimeout(function() { + if (!listItem.selected || !listItem.lead) + listItem.expanded = false; + }, 0); + } else if (listItem.lead) { + listItem.expanded = true; + } + } + }, this); + }, + + /** + * Called on selection model lead changes. + * @param {Event} pe The lead change event. + * @private + */ + cookieLeadChange_: function(pe) { + if (pe.oldValue != -1) { + var listItem = this.getListItemByIndex(pe.oldValue); + if (listItem) { + // See cookieSelectionChange_ above for why we use a timeout here. + window.setTimeout(function() { + if (!listItem.lead || !listItem.selected) + listItem.expanded = false; + }, 0); + } + } + if (pe.newValue != -1) { + var listItem = this.getListItemByIndex(pe.newValue); + if (listItem && listItem.selected) + listItem.expanded = true; + } + }, + + /** + * The currently expanded item. Used by IntentsListItem above. + * @type {?IntentsListItem} + */ + expandedItem: null, + + // from cr.ui.List + /** @inheritDoc */ + createItem: function(data) { + // We use the cached expanded item in order to allow it to maintain some + // state (like its fixed height, and which bubble is selected). + if (this.expandedItem && this.expandedItem.origin == data) + return this.expandedItem; + return new IntentsListItem(data, this); + }, + + // from options.DeletableItemList + /** @inheritDoc */ + deleteItemAtIndex: function(index) { + var item = this.data_[index]; + if (item) { + var pathId = item.pathId; + if (pathId) + chrome.send('removeIntent', [pathId]); + } + }, + + /** + * Insert an intents tree node at the given index. + * Both IntentsList and IntentsTreeNode implement this API. + * @param {Object} data The data object for the node to add. + * @param {number} index The index at which to insert the node. + */ + insertAt: function(data, index) { + this.dataModel.splice(index, 0, new IntentsTreeNode(data)); + }, + + /** + * Remove an intents tree node from the given index. + * Both IntentsList and IntentsTreeNode implement this API. + * @param {number} index The index of the tree node to remove. + */ + remove: function(index) { + if (index < this.data_.length) + this.dataModel.splice(index, 1); + }, + + /** + * Clears the list. + * Both IntentsList and IntentsTreeNode implement this API. + * It is used by IntentsList.loadChildren(). + */ + clear: function() { + parentLookup = {}; + this.data_ = []; + this.dataModel = new ArrayDataModel(this.data_); + this.redraw(); + }, + + /** + * Add tree nodes by given parent. + * Note: this method will be O(n^2) in the general case. Use it only to + * populate an empty parent or to insert single nodes to avoid this. + * @param {Object} parent The parent node. + * @param {number} start Start index of where to insert nodes. + * @param {Array} nodesData Nodes data array. + * @private + */ + addByParent_: function(parent, start, nodesData) { + if (!parent) + return; + + parent.startBatchUpdates(); + for (var i = 0; i < nodesData.length; ++i) + parent.insertAt(nodesData[i], start + i); + parent.endBatchUpdates(); + + cr.dispatchSimpleEvent(this, 'change'); + }, + + /** + * Add tree nodes by parent id. + * This is used by intents_view.js. + * Note: this method will be O(n^2) in the general case. Use it only to + * populate an empty parent or to insert single nodes to avoid this. + * @param {string} parentId Id of the parent node. + * @param {number} start Start index of where to insert nodes. + * @param {Array} nodesData Nodes data array. + */ + addByParentId: function(parentId, start, nodesData) { + var parent = parentId ? parentLookup[parentId] : this; + this.addByParent_(parent, start, nodesData); + }, + + /** + * Removes tree nodes by parent id. + * This is used by intents_view.js. + * @param {string} parentId Id of the parent node. + * @param {number} start Start index of nodes to remove. + * @param {number} count Number of nodes to remove. + */ + removeByParentId: function(parentId, start, count) { + var parent = parentId ? parentLookup[parentId] : this; + if (!parent) + return; + + parent.startBatchUpdates(); + while (count-- > 0) + parent.remove(start); + parent.endBatchUpdates(); + + cr.dispatchSimpleEvent(this, 'change'); + }, + + /** + * Loads the immediate children of given parent node. + * This is used by intents_view.js. + * @param {string} parentId Id of the parent node. + * @param {Array} children The immediate children of parent node. + */ + loadChildren: function(parentId, children) { + console.log('Loading intents view: ' + + parentId + ' ' + JSON.stringify(children)); + if (parentId) + delete lookupRequests[parentId]; + var parent = parentId ? parentLookup[parentId] : this; + if (!parent) + return; + + parent.startBatchUpdates(); + parent.clear(); + this.addByParent_(parent, 0, children); + parent.endBatchUpdates(); + }, + }; + + return { + IntentsList: IntentsList + }; +}); diff --git a/chrome/browser/resources/options/intents_view.css b/chrome/browser/resources/options/intents_view.css new file mode 100644 index 0000000..84c274d --- /dev/null +++ b/chrome/browser/resources/options/intents_view.css @@ -0,0 +1,181 @@ +/* +Copyright (c) 2011 The Chromium Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +*/ + +/* Styles for the intents list elements in intents_view.html. */ + +#intents-column-headers { + position: relative; + width: 100%; +} + +#intents-column-headers h3 { + font-size: 105%; + font-weight: bold; + margin: 10px 0; +} + +/* Notice the width and padding for these columns match up with those below. */ +#intents-site-column { + display: inline-block; + font-weight: bold; + width: 11em; +} + +#intents-data-column { + -webkit-padding-start: 7px; + display: inline-block; + font-weight: bold; +} + +#intents-list { + border: 1px solid #d9d9d9; + margin: 0; +} + +/* Enable animating the height of items. */ +list.intents-list .deletable-item { + -webkit-transition: height .15s ease-in-out; +} + +/* Disable webkit-box display. */ +list.intents-list .deletable-item > :first-child { + display: block; +} + +/* Force the X for deleting an origin to stay at the top. */ +list.intents-list > .deletable-item > .close-button { + position: absolute; + right: 2px; + top: 8px; +} + +html[dir=rtl] list.intents-list > .deletable-item > .close-button { + left: 2px; + right: auto; +} + +/* Styles for the site (aka origin) and its summary. */ + +.intents-site { + /* Notice that the width, margin, and padding match up with those above. */ + -webkit-margin-end: 2px; + -webkit-padding-start: 5px; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + width: 11em; +} + +list.intents-list > .deletable-item[selected] .intents-site { + -webkit-user-select: text; +} + +.intents-data { + display: inline-block; +} + +list.intents-list > .deletable-item[selected] .intents-data { + -webkit-user-select: text; +} + +.intents-items { + /* Notice that the margin and padding match up with those above. */ + -webkit-margin-start: 11em; + -webkit-padding-start: 7px; + -webkit-transition: .15s ease-in-out; + display: table; + height: 0; + opacity: 0; + /* Make the intents items wrap correctly. */ + white-space: normal; +} + +.measure-items .intents-items { + -webkit-transition: none; + height: auto; + visibility: hidden; +} + +.show-items .intents-items { + opacity: 1; +} + +.intents-items .intents-item { + -webkit-box-orient: horizontal; + -webkit-box-pack: start; + background: #e0e9f5; + border: 1px solid #8392ae; + display: table-row; + font-size: 85%; + height: auto; + margin: 2px 4px 2px 0; + overflow: hidden; + padding: 0 3px; + text-align: center; + text-overflow: ellipsis; +} + +.intents-item .intents-item-action { + display: table-cell; + padding: 2px 5px; +} + +.intents-item .intents-item-types { + display: table-cell; + padding: 2px 5px; + overflow: hidden; +} + +.intents-item .intents-item-url { + display: table-cell; + padding: 2px 5px; + overflow: hidden; + text-overflow: ellipsis; +} + +.intents-items .intents-item:hover { + background: #eef3f9; + border-color: #647187; +} + +.intents-items .intents-item[selected] { + background: #f5f8f8; + border-color: #b2b2b2; +} + +.intents-items .intents-item[selected]:hover { + background: #f5f8f8; + border-color: #647187; +} + +/* Styles for the intents details box. */ + +.intents-details { + background: #f5f8f8; + border-radius: 5px; + border: 1px solid #b2b2b2; + margin-top: 2px; + padding: 5px; +} + +list.intents-list > .deletable-item[selected] .intents-details { + -webkit-user-select: text; +} + +.intents-details-table { + table-layout: fixed; + width: 100%; +} + +.intents-details-label { + vertical-align: top; + white-space: pre; + width: 10em; +} + +.intents-details-value { + word-wrap: break-word; +} diff --git a/chrome/browser/resources/options/intents_view.html b/chrome/browser/resources/options/intents_view.html new file mode 100644 index 0000000..6803c23 --- /dev/null +++ b/chrome/browser/resources/options/intents_view.html @@ -0,0 +1,12 @@ +<div id="intents-view-page" class="page" hidden> + <h1 i18n-content="intentsViewPage"></h1> + <div id="intents-column-headers"> + <div id="intents-site-column"> + <h3 i18n-content="intentsDomain"></h3> + </div> + <div id="intents-data-column"> + <h3 i18n-content="intentsServiceData"></h3> + </div> + </div> + <list id="intents-list"></list> +</div> diff --git a/chrome/browser/resources/options/intents_view.js b/chrome/browser/resources/options/intents_view.js new file mode 100644 index 0000000..afc7d68 --- /dev/null +++ b/chrome/browser/resources/options/intents_view.js @@ -0,0 +1,83 @@ +// Copyright (c) 2011 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +cr.define('options', function() { + var OptionsPage = options.OptionsPage; + + ///////////////////////////////////////////////////////////////////////////// + // IntentsView class: + + /** + * Encapsulated handling of the Intents data page. + * @constructor + */ + function IntentsView(model) { + OptionsPage.call(this, 'intents', + templateData.intentsViewPageTabTitle, + 'intents-view-page'); + } + + cr.addSingletonGetter(IntentsView); + + IntentsView.prototype = { + __proto__: OptionsPage.prototype, + + initializePage: function() { + OptionsPage.prototype.initializePage.call(this); + + var intentsList = $('intents-list'); + options.IntentsList.decorate(intentsList); + window.addEventListener('resize', this.handleResize_.bind(this)); + + this.addEventListener('visibleChange', this.handleVisibleChange_); + }, + + initialized_: false, + + /** + * Handler for OptionsPage's visible property change event. + * @param {Event} e Property change event. + * @private + */ + handleVisibleChange_: function(e) { + if (!this.visible) + return; + + // Resize the intents list whenever the options page becomes visible. + this.handleResize_(null); + if (!this.initialized_) { + this.initialized_ = true; + chrome.send('loadIntents'); + } else { + $('intents-list').redraw(); + } + }, + + /** + * Handler for when the window changes size. Resizes the intents list to + * match the window height. + * @param {?Event} e Window resize event, or null if called directly. + * @private + */ + handleResize_: function(e) { + if (!this.visible) + return; + var intentsList = $('intents-list'); + // 25 pixels from the window bottom seems like a visually pleasing amount. + var height = window.innerHeight - intentsList.offsetTop - 25; + intentsList.style.height = height + 'px'; + }, + }; + + // IntentsViewHandler callbacks. + IntentsView.loadChildren = function(args) { + $('intents-list').loadChildren(args[0], args[1]); + }; + + // Export + return { + IntentsView: IntentsView + }; + +}); diff --git a/chrome/browser/resources/options/options.html b/chrome/browser/resources/options/options.html index 3fb7c74..de3754c 100644 --- a/chrome/browser/resources/options/options.html +++ b/chrome/browser/resources/options/options.html @@ -29,6 +29,9 @@ <link rel="stylesheet" href="handler_options.css"> </if> <link rel="stylesheet" href="import_data_overlay.css"> +<if expr="pp_ifdef('enable_web_intents')"> + <link rel="stylesheet" href="intents_view.css"> +</if> <link rel="stylesheet" href="language_options.css"> <link rel="stylesheet" href="manage_profile_overlay.css"> <link rel="stylesheet" href="password_manager.css"> @@ -164,6 +167,9 @@ <if expr="pp_ifdef('enable_register_protocol_handler')"> <include src="handler_options.html"> </if> + <if expr="pp_ifdef('enable_web_intents')"> + <include src="intents_view.html"> + </if> <include src="content_settings_exceptions_area.html"> </div> </div> diff --git a/chrome/browser/resources/options/options.js b/chrome/browser/resources/options/options.js index de55f64..3e5c41b 100644 --- a/chrome/browser/resources/options/options.js +++ b/chrome/browser/resources/options/options.js @@ -17,6 +17,7 @@ var CookiesView = options.CookiesView; var FontSettings = options.FontSettings; var HandlerOptions = options.HandlerOptions; var ImportDataOverlay = options.ImportDataOverlay; +var IntentsView = options.IntentsView; var InstantConfirmOverlay = options.InstantConfirmOverlay; var LanguageOptions = options.LanguageOptions; var OptionsPage = options.OptionsPage; @@ -120,6 +121,11 @@ function load() { ContentSettings.getInstance(), [$('manage-handlers-button')]); } + if (IntentsView && $('manage-intents-button')) { + OptionsPage.registerSubPage(IntentsView.getInstance(), + ContentSettings.getInstance(), + [$('manage-intents-button')]); + } OptionsPage.registerSubPage(FontSettings.getInstance(), AdvancedOptions.getInstance(), [$('fontSettingsCustomizeFontsButton')]); diff --git a/chrome/browser/resources/options/options_bundle.js b/chrome/browser/resources/options/options_bundle.js index a181de9..0b58a74 100644 --- a/chrome/browser/resources/options/options_bundle.js +++ b/chrome/browser/resources/options/options_bundle.js @@ -68,6 +68,10 @@ </if> <include src="import_data_overlay.js"></include> <include src="instant_confirm_overlay.js"></include> +<if expr="pp_ifdef('enable_web_intents')"> + <include src="intents_list.js"></include> + <include src="intents_view.js"></include> +</if> <include src="language_add_language_overlay.js"></include> <include src="language_list.js"></include> <include src="language_options.js"></include> diff --git a/chrome/browser/ui/intents/intents_model.cc b/chrome/browser/ui/intents/intents_model.cc new file mode 100644 index 0000000..919dbe3 --- /dev/null +++ b/chrome/browser/ui/intents/intents_model.cc @@ -0,0 +1,162 @@ +// Copyright (c) 2011 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/intents/intents_model.h" +#include "base/string_split.h" +#include "base/string_util.h" +#include "base/stringprintf.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/intents/web_intents_registry.h" + +IntentsTreeNode::IntentsTreeNode() + : ui::TreeNode<IntentsTreeNode>(string16()), + type_(TYPE_ROOT) {} + +IntentsTreeNode::IntentsTreeNode(const string16& title) + : ui::TreeNode<IntentsTreeNode>(title), + type_(TYPE_ORIGIN) {} + +IntentsTreeNode::~IntentsTreeNode() {} + +ServiceTreeNode::ServiceTreeNode(const string16& title) + : IntentsTreeNode(title, IntentsTreeNode::TYPE_SERVICE), + blocked_(false), + disabled_(false) {} + +ServiceTreeNode::~ServiceTreeNode() {} + +IntentsModel::IntentsModel(WebIntentsRegistry* intents_registry) + : ui::TreeNodeModel<IntentsTreeNode>(new IntentsTreeNode()), + intents_registry_(intents_registry), + batch_update_(0) { + LoadModel(); +} + +IntentsModel::~IntentsModel() {} + +void IntentsModel::AddIntentsTreeObserver(Observer* observer) { + intents_observer_list_.AddObserver(observer); + // Call super so that TreeNodeModel can notify, too. + ui::TreeNodeModel<IntentsTreeNode>::AddObserver(observer); +} + +void IntentsModel::RemoveIntentsTreeObserver(Observer* observer) { + intents_observer_list_.RemoveObserver(observer); + // Call super so that TreeNodeModel doesn't have dead pointers. + ui::TreeNodeModel<IntentsTreeNode>::RemoveObserver(observer); +} + +string16 IntentsModel::GetTreeNodeId(IntentsTreeNode* node) { + if (node->Type() == IntentsTreeNode::TYPE_ORIGIN) + return node->GetTitle(); + + // TODO(gbillock): handle TYPE_SERVICE when/if we ever want to do + // specific managing of them. + + return string16(); +} + +IntentsTreeNode* IntentsModel::GetTreeNode(std::string path_id) { + if (path_id.empty()) + return GetRoot(); + + std::vector<std::string> node_ids; + base::SplitString(path_id, ',', &node_ids); + + for (int i = 0; i < GetRoot()->child_count(); ++i) { + IntentsTreeNode* node = GetRoot()->GetChild(i); + if (UTF16ToUTF8(node->GetTitle()) == node_ids[0]) { + if (node_ids.size() == 1) + return node; + } + } + + // TODO: support service nodes? + return NULL; +} + +void IntentsModel::GetChildNodeList(IntentsTreeNode* parent, + int start, int count, + base::ListValue* nodes) { + for (int i = 0; i < count; ++i) { + base::DictionaryValue* dict = new base::DictionaryValue; + IntentsTreeNode* child = parent->GetChild(start + i); + GetIntentsTreeNodeDictionary(*child, dict); + nodes->Append(dict); + } +} + +void IntentsModel::GetIntentsTreeNodeDictionary(const IntentsTreeNode& node, + base::DictionaryValue* dict) { + if (node.Type() == IntentsTreeNode::TYPE_ROOT) { + return; + } + + if (node.Type() == IntentsTreeNode::TYPE_ORIGIN) { + dict->SetString("site", node.GetTitle()); + dict->SetBoolean("hasChildren", node.child_count() > 0); + return; + } + + if (node.Type() == IntentsTreeNode::TYPE_SERVICE) { + const ServiceTreeNode* snode = static_cast<const ServiceTreeNode*>(&node); + dict->SetString("site", snode->GetTitle()); + dict->SetString("name", snode->ServiceName()); + dict->SetString("url", snode->ServiceUrl()); + dict->SetString("icon", snode->IconUrl()); + dict->SetString("action", snode->Action()); + dict->Set("types", snode->Types().DeepCopy()); + dict->SetBoolean("blocked", snode->IsBlocked()); + dict->SetBoolean("disabled", snode->IsDisabled()); + return; + } +} + +void IntentsModel::LoadModel() { + NotifyObserverBeginBatch(); + intents_registry_->GetAllIntentProviders(this); +} + +void IntentsModel::OnIntentsQueryDone( + WebIntentsRegistry::QueryID query_id, + const std::vector<WebIntentData>& intents) { + for (size_t i = 0; i < intents.size(); ++i) { + // Eventually do some awesome sorting, grouping, clustering stuff here. + // For now, just stick it in the model flat. + IntentsTreeNode* n = new IntentsTreeNode(ASCIIToUTF16( + intents[i].service_url.host())); + ServiceTreeNode* ns = new ServiceTreeNode(ASCIIToUTF16( + intents[i].service_url.host())); + ns->SetServiceName(intents[i].title); + ns->SetServiceUrl(ASCIIToUTF16(intents[i].service_url.spec())); + GURL icon_url = intents[i].service_url.GetOrigin().Resolve("/favicon.ico"); + ns->SetIconUrl(ASCIIToUTF16(icon_url.spec())); + ns->SetAction(intents[i].action); + ns->AddType(intents[i].type); + // Won't generate a notification. OK for now as the next line will. + n->Add(ns, 0); + Add(GetRoot(), n, GetRoot()->child_count()); + } + + NotifyObserverEndBatch(); +} + +void IntentsModel::NotifyObserverBeginBatch() { + // Only notify the model once if we're batching in a nested manner. + if (batch_update_++ == 0) { + FOR_EACH_OBSERVER(Observer, + intents_observer_list_, + TreeModelBeginBatch(this)); + } +} + +void IntentsModel::NotifyObserverEndBatch() { + // Only notify the observers if this is the outermost call to EndBatch() if + // called in a nested manner. + if (--batch_update_ == 0) { + FOR_EACH_OBSERVER(Observer, + intents_observer_list_, + TreeModelEndBatch(this)); + } +} diff --git a/chrome/browser/ui/intents/intents_model.h b/chrome/browser/ui/intents/intents_model.h new file mode 100644 index 0000000..14e8bbf --- /dev/null +++ b/chrome/browser/ui/intents/intents_model.h @@ -0,0 +1,128 @@ +// Copyright (c) 2011 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_INTENTS_INTENTS_MODEL_H_ +#define CHROME_BROWSER_UI_INTENTS_INTENTS_MODEL_H_ +#pragma once + +#include "base/values.h" +#include "chrome/browser/intents/web_intents_registry.h" +#include "ui/base/models/tree_node_model.h" + +class WebIntentsRegistry; + +// The tree structure is a TYPE_ROOT node with title="", +// children are TYPE_ORIGIN nodes with title=origin, whose +// children are TYPE_SERVICE nodes with title=origin, and +// will be of type ServiceTreeNode with data on individual +// services. +class IntentsTreeNode : public ui::TreeNode<IntentsTreeNode> { + public: + IntentsTreeNode(); + explicit IntentsTreeNode(const string16& title); + + virtual ~IntentsTreeNode(); + + enum NodeType { + TYPE_ROOT, + TYPE_ORIGIN, + TYPE_SERVICE, + }; + + NodeType Type() const { return type_; } + + protected: + IntentsTreeNode(const string16& title, NodeType type) + : ui::TreeNode<IntentsTreeNode>(title), + type_(type) {} + + private: + NodeType type_; +}; + +// Tree node representing particular services presented by an origin. +class ServiceTreeNode : public IntentsTreeNode { + public: + explicit ServiceTreeNode(const string16& title); + virtual ~ServiceTreeNode(); + + const string16& ServiceName() const { return service_name_; } + const string16& ServiceUrl() const { return service_url_; } + const string16& IconUrl() const { return icon_url_; } + const string16& Action() const { return action_; } + const base::ListValue& Types() const { return types_; } + bool IsBlocked() const { return blocked_; } + bool IsDisabled() const { return disabled_; } + + void SetServiceName(string16 name) { service_name_ = name; } + void SetServiceUrl(string16 url) { service_url_ = url; } + void SetIconUrl(string16 url) { icon_url_ = url; } + void SetAction(string16 action) { action_ = action; } + void AddType(string16 type) { types_.Append(Value::CreateStringValue(type)); } + void SetBlocked(bool blocked) { blocked_ = blocked; } + void SetDisabled(bool disabled) { disabled_ = disabled; } + + private: + string16 service_name_; + string16 icon_url_; + string16 service_url_; + string16 action_; + base::ListValue types_; + + // TODO(gbillock): these are kind of a placeholder for exceptions data. + bool blocked_; + bool disabled_; +}; + +// UI-backing tree model of the data in the WebIntentsRegistry. +class IntentsModel : public ui::TreeNodeModel<IntentsTreeNode>, + public WebIntentsRegistry::Consumer { + public: + // Because nodes are fetched in a background thread, they are not + // present at the time the Model is created. The Model then notifies its + // observers for every item added. + class Observer : public ui::TreeModelObserver { + public: + virtual void TreeModelBeginBatch(IntentsModel* model) {} + virtual void TreeModelEndBatch(IntentsModel* model) {} + }; + + explicit IntentsModel(WebIntentsRegistry* intents_registry); + virtual ~IntentsModel(); + + void AddIntentsTreeObserver(Observer* observer); + void RemoveIntentsTreeObserver(Observer* observer); + + string16 GetTreeNodeId(IntentsTreeNode* node); + IntentsTreeNode* GetTreeNode(std::string path_id); + void GetChildNodeList(IntentsTreeNode* parent, int start, int count, + base::ListValue* nodes); + void GetIntentsTreeNodeDictionary(const IntentsTreeNode& node, + base::DictionaryValue* dict); + + virtual void OnIntentsQueryDone( + WebIntentsRegistry::QueryID query_id, + const std::vector<WebIntentData>& intents) OVERRIDE; + + private: + // Loads the data model from the WebIntentsRegistry. + // TODO(gbillock): need an observer on that to absorb async updates? + void LoadModel(); + + // Do batch-specific notifies for updates coming from the LoadModel. + void NotifyObserverBeginBatch(); + void NotifyObserverEndBatch(); + + // The backing registry. Weak pointer. + WebIntentsRegistry* intents_registry_; + + // Separate list of observers that'll get batch updates. + ObserverList<Observer> intents_observer_list_; + + // Batch update nesting level. Incremented to indicate that we're in + // the middle of a batch update. + int batch_update_; +}; + +#endif // CHROME_BROWSER_UI_INTENTS_INTENTS_MODEL_H_ diff --git a/chrome/browser/ui/intents/intents_model_unittest.cc b/chrome/browser/ui/intents/intents_model_unittest.cc new file mode 100644 index 0000000..175e546 --- /dev/null +++ b/chrome/browser/ui/intents/intents_model_unittest.cc @@ -0,0 +1,185 @@ +// Copyright (c) 2011 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/file_util.h" +#include "base/scoped_temp_dir.h" +#include "base/synchronization/waitable_event.h" +#include "base/values.h" +#include "base/utf_string_conversions.h" +#include "chrome/browser/intents/web_intents_registry.h" +#include "chrome/browser/webdata/web_data_service.h" +#include "chrome/test/base/testing_browser_process_test.h" +#include "chrome/browser/ui/intents/intents_model.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "ui/base/models/tree_node_model.h" + +class IntentsModelTest : public TestingBrowserProcessTest { + public: + IntentsModelTest() + : ui_thread_(BrowserThread::UI, &message_loop_), + db_thread_(BrowserThread::DB) {} + + protected: + virtual void SetUp() { + db_thread_.Start(); + wds_ = new WebDataService(); + ASSERT_TRUE(temp_dir_.CreateUniqueTempDir()); + wds_->Init(temp_dir_.path()); + + registry_.Initialize(wds_); + } + + virtual void TearDown() { + if (wds_.get()) + wds_->Shutdown(); + + db_thread_.Stop(); + MessageLoop::current()->PostTask(FROM_HERE, new MessageLoop::QuitTask); + MessageLoop::current()->Run(); + } + + void LoadRegistry() { + { + WebIntentData provider; + provider.service_url = GURL("http://www.google.com/share"); + provider.action = ASCIIToUTF16("SHARE"); + provider.type = ASCIIToUTF16("text/url"); + provider.title = ASCIIToUTF16("Google"); + registry_.RegisterIntentProvider(provider); + } + { + WebIntentData provider; + provider.service_url = GURL("http://picasaweb.google.com/share"); + provider.action = ASCIIToUTF16("EDIT"); + provider.type = ASCIIToUTF16("image/*"); + provider.title = ASCIIToUTF16("Picasa"); + registry_.RegisterIntentProvider(provider); + } + { + WebIntentData provider; + provider.service_url = GURL("http://www.digg.com/share"); + provider.action = ASCIIToUTF16("SHARE"); + provider.type = ASCIIToUTF16("text/url"); + provider.title = ASCIIToUTF16("Digg"); + registry_.RegisterIntentProvider(provider); + } + } + + MessageLoopForUI message_loop_; + BrowserThread ui_thread_; + BrowserThread db_thread_; + scoped_refptr<WebDataService> wds_; + WebIntentsRegistry registry_; + ScopedTempDir temp_dir_; +}; + +class WaitingIntentsObserver : public IntentsModel::Observer { + public: + WaitingIntentsObserver() : event_(true, false), added_(0) {} + + virtual void TreeModelBeginBatch(IntentsModel* model) {} + + virtual void TreeModelEndBatch(IntentsModel* model) { + event_.Signal(); + MessageLoop::current()->Quit(); + } + + virtual void TreeNodesAdded(ui::TreeModel* model, + ui::TreeModelNode* parent, + int start, + int count) { + added_++; + } + + virtual void TreeNodesRemoved(ui::TreeModel* model, + ui::TreeModelNode* node, + int start, + int count) { + } + + virtual void TreeNodeChanged(ui::TreeModel* model, ui::TreeModelNode* node) { + } + + void Wait() { + MessageLoop::current()->Run(); + event_.Wait(); + LOG(INFO) << "DONE!"; + } + + base::WaitableEvent event_; + int added_; +}; + +TEST_F(IntentsModelTest, NodeIDs) { + LoadRegistry(); + WaitingIntentsObserver obs; + IntentsModel intents_model(®istry_); + intents_model.AddIntentsTreeObserver(&obs); + obs.Wait(); + + IntentsTreeNode* n1 = new IntentsTreeNode(ASCIIToUTF16("origin")); + intents_model.Add(intents_model.GetRoot(), n1, + intents_model.GetRoot()->child_count()); + EXPECT_EQ(ASCIIToUTF16("origin"), intents_model.GetTreeNodeId(n1)); + + IntentsTreeNode* ncheck = intents_model.GetTreeNode("origin"); + EXPECT_EQ(ncheck, n1); + + base::ListValue nodes; + intents_model.GetChildNodeList( + intents_model.GetTreeNode("www.google.com"), 0, 1, &nodes); + EXPECT_EQ(static_cast<size_t>(1), nodes.GetSize()); + base::DictionaryValue* dict; + EXPECT_TRUE(nodes.GetDictionary(0, &dict)); + + std::string val; + EXPECT_TRUE(dict->GetString("site", &val)); + EXPECT_EQ("www.google.com", val); + EXPECT_TRUE(dict->GetString("name", &val)); + EXPECT_EQ("Google", val); + EXPECT_TRUE(dict->GetString("url", &val)); + EXPECT_EQ("http://www.google.com/share", val); + EXPECT_TRUE(dict->GetString("icon", &val)); + EXPECT_EQ("http://www.google.com/favicon.ico", val); + base::ListValue* types_list; + EXPECT_TRUE(dict->GetList("types", &types_list)); + EXPECT_EQ(static_cast<size_t>(1), types_list->GetSize()); + EXPECT_TRUE(types_list->GetString(0, &val)); + EXPECT_EQ("text/url", val); + bool bval; + EXPECT_TRUE(dict->GetBoolean("blocked", &bval)); + EXPECT_FALSE(bval); + EXPECT_TRUE(dict->GetBoolean("disabled", &bval)); + EXPECT_FALSE(bval); +} + +TEST_F(IntentsModelTest, LoadFromWebData) { + LoadRegistry(); + WaitingIntentsObserver obs; + IntentsModel intents_model(®istry_); + intents_model.AddIntentsTreeObserver(&obs); + obs.Wait(); + EXPECT_EQ(3, obs.added_); + + IntentsTreeNode* node = intents_model.GetTreeNode("www.google.com"); + ASSERT_NE(static_cast<IntentsTreeNode*>(NULL), node); + EXPECT_EQ(IntentsTreeNode::TYPE_ORIGIN, node->Type()); + EXPECT_EQ(ASCIIToUTF16("www.google.com"), node->GetTitle()); + EXPECT_EQ(1, node->child_count()); + node = node->GetChild(0); + ASSERT_EQ(IntentsTreeNode::TYPE_SERVICE, node->Type()); + ServiceTreeNode* snode = static_cast<ServiceTreeNode*>(node); + EXPECT_EQ(ASCIIToUTF16("Google"), snode->ServiceName()); + EXPECT_EQ(ASCIIToUTF16("SHARE"), snode->Action()); + EXPECT_EQ(ASCIIToUTF16("http://www.google.com/share"), snode->ServiceUrl()); + EXPECT_EQ(static_cast<size_t>(1), snode->Types().GetSize()); + string16 stype; + ASSERT_TRUE(snode->Types().GetString(0, &stype)); + EXPECT_EQ(ASCIIToUTF16("text/url"), stype); + + node = intents_model.GetTreeNode("www.digg.com"); + ASSERT_NE(static_cast<IntentsTreeNode*>(NULL), node); + EXPECT_EQ(IntentsTreeNode::TYPE_ORIGIN, node->Type()); + EXPECT_EQ(ASCIIToUTF16("www.digg.com"), node->GetTitle()); +} diff --git a/chrome/browser/ui/webui/options/intents_settings_handler.cc b/chrome/browser/ui/webui/options/intents_settings_handler.cc new file mode 100644 index 0000000..42f4868 --- /dev/null +++ b/chrome/browser/ui/webui/options/intents_settings_handler.cc @@ -0,0 +1,159 @@ +// Copyright (c) 2011 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "chrome/browser/ui/webui/options/intents_settings_handler.h" + +#include "base/utf_string_conversions.h" +#include "base/values.h" +#include "chrome/browser/browsing_data_appcache_helper.h" +#include "chrome/browser/browsing_data_database_helper.h" +#include "chrome/browser/browsing_data_file_system_helper.h" +#include "chrome/browser/browsing_data_indexed_db_helper.h" +#include "chrome/browser/browsing_data_local_storage_helper.h" +#include "chrome/browser/intents/web_intents_registry.h" +#include "chrome/browser/intents/web_intents_registry_factory.h" +#include "chrome/browser/profiles/profile.h" +#include "chrome/browser/webdata/web_data_service.h" +#include "content/browser/webui/web_ui.h" +#include "grit/generated_resources.h" +#include "net/url_request/url_request_context_getter.h" +#include "ui/base/l10n/l10n_util.h" + +IntentsSettingsHandler::IntentsSettingsHandler() : batch_update_(false) { +} + +IntentsSettingsHandler::~IntentsSettingsHandler() { +} + +void IntentsSettingsHandler::GetLocalizedValues( + DictionaryValue* localized_strings) { + DCHECK(localized_strings); + + static OptionsStringResource resources[] = { + { "intentsDomain", IDS_INTENTS_DOMAIN_COLUMN_HEADER }, + { "intentsServiceData", IDS_INTENTS_SERVICE_DATA_COLUMN_HEADER }, + { "manageIntents", IDS_INTENTS_MANAGE_BUTTON }, + { "removeIntent", IDS_INTENTS_REMOVE_INTENT_BUTTON }, + }; + + RegisterStrings(localized_strings, resources, arraysize(resources)); + RegisterTitle(localized_strings, "intentsViewPage", + IDS_INTENTS_MANAGER_WINDOW_TITLE); +} + +void IntentsSettingsHandler::RegisterMessages() { + web_ui_->RegisterMessageCallback("removeIntent", + NewCallback(this, &IntentsSettingsHandler::RemoveIntent)); + web_ui_->RegisterMessageCallback("loadIntents", + NewCallback(this, &IntentsSettingsHandler::LoadChildren)); +} + +void IntentsSettingsHandler::TreeNodesAdded(ui::TreeModel* model, + ui::TreeModelNode* parent, + int start, + int count) { + SendChildren(intents_tree_model_->GetRoot()); +} + +void IntentsSettingsHandler::TreeNodesRemoved(ui::TreeModel* model, + ui::TreeModelNode* parent, + int start, + int count) { + SendChildren(intents_tree_model_->GetRoot()); +} + +void IntentsSettingsHandler::TreeModelBeginBatch(IntentsModel* model) { + batch_update_ = true; +} + +void IntentsSettingsHandler::TreeModelEndBatch(IntentsModel* model) { + batch_update_ = false; + + SendChildren(intents_tree_model_->GetRoot()); +} + +void IntentsSettingsHandler::EnsureIntentsModelCreated() { + if (intents_tree_model_.get()) return; + + Profile* profile = Profile::FromWebUI(web_ui_); + web_data_service_ = profile->GetWebDataService(Profile::EXPLICIT_ACCESS); + web_intents_registry_ = WebIntentsRegistryFactory::GetForProfile(profile); + web_intents_registry_->Initialize(web_data_service_.get()); + intents_tree_model_.reset(new IntentsModel(web_intents_registry_)); + intents_tree_model_->AddIntentsTreeObserver(this); +} + +void IntentsSettingsHandler::RemoveIntent(const base::ListValue* args) { + std::string node_path; + if (!args->GetString(0, &node_path)) { + return; + } + + EnsureIntentsModelCreated(); + + IntentsTreeNode* node = intents_tree_model_->GetTreeNode(node_path); + if (node->Type() == IntentsTreeNode::TYPE_ORIGIN) { + RemoveOrigin(node); + } else if (node->Type() == IntentsTreeNode::TYPE_SERVICE) { + ServiceTreeNode* snode = static_cast<ServiceTreeNode*>(node); + RemoveService(snode); + } +} + +void IntentsSettingsHandler::RemoveOrigin(IntentsTreeNode* node) { + // TODO(gbillock): This is a known batch update. Worth optimizing? + while (node->child_count() > 0) { + IntentsTreeNode* cnode = node->GetChild(0); + CHECK(cnode->Type() == IntentsTreeNode::TYPE_SERVICE); + ServiceTreeNode* snode = static_cast<ServiceTreeNode*>(cnode); + RemoveService(snode); + } + delete intents_tree_model_->Remove(node->parent(), node); +} + +void IntentsSettingsHandler::RemoveService(ServiceTreeNode* snode) { + WebIntentData provider; + provider.service_url = GURL(snode->ServiceUrl()); + provider.action = snode->Action(); + string16 stype; + if (snode->Types().GetString(0, &stype)) { + provider.type = stype; // Really need to iterate here. + } + provider.title = snode->ServiceName(); + LOG(INFO) << "Removing service " << snode->ServiceName() + << " " << snode->ServiceUrl(); + web_intents_registry_->UnregisterIntentProvider(provider); + delete intents_tree_model_->Remove(snode->parent(), snode); +} + +void IntentsSettingsHandler::LoadChildren(const base::ListValue* args) { + EnsureIntentsModelCreated(); + + std::string node_path; + if (!args->GetString(0, &node_path)) { + SendChildren(intents_tree_model_->GetRoot()); + return; + } + + IntentsTreeNode* node = intents_tree_model_->GetTreeNode(node_path); + SendChildren(node); +} + +void IntentsSettingsHandler::SendChildren(IntentsTreeNode* parent) { + // Early bailout during batch updates. We'll get one after the batch concludes + // with batch_update_ set false. + if (batch_update_) return; + + ListValue* children = new ListValue; + intents_tree_model_->GetChildNodeList(parent, 0, parent->child_count(), + children); + + ListValue args; + args.Append(parent == intents_tree_model_->GetRoot() ? + Value::CreateNullValue() : + Value::CreateStringValue(intents_tree_model_->GetTreeNodeId(parent))); + args.Append(children); + + web_ui_->CallJavascriptFunction("IntentsView.loadChildren", args); +} diff --git a/chrome/browser/ui/webui/options/intents_settings_handler.h b/chrome/browser/ui/webui/options/intents_settings_handler.h new file mode 100644 index 0000000..957284c --- /dev/null +++ b/chrome/browser/ui/webui/options/intents_settings_handler.h @@ -0,0 +1,78 @@ +// Copyright (c) 2011 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef CHROME_BROWSER_UI_WEBUI_OPTIONS_INTENTS_SETTINGS_HANDLER_H_ +#define CHROME_BROWSER_UI_WEBUI_OPTIONS_INTENTS_SETTINGS_HANDLER_H_ +#pragma once + +#include "base/compiler_specific.h" +#include "base/memory/scoped_ptr.h" +#include "chrome/browser/ui/intents/intents_model.h" +#include "chrome/browser/ui/webui/options/options_ui.h" + +class WebDataService; +class WebIntentsRegistry; + +// Manage setting up the backing data for the web intents options page. +class IntentsSettingsHandler : public OptionsPageUIHandler, + public IntentsModel::Observer { + public: + IntentsSettingsHandler(); + virtual ~IntentsSettingsHandler(); + + // OptionsPageUIHandler implementation. + virtual void GetLocalizedValues(base::DictionaryValue* localized_strings); + virtual void RegisterMessages(); + + // IntentsModel::Observer implementation. + virtual void TreeNodesAdded(ui::TreeModel* model, + ui::TreeModelNode* parent, + int start, + int count) OVERRIDE; + virtual void TreeNodesRemoved(ui::TreeModel* model, + ui::TreeModelNode* parent, + int start, + int count) OVERRIDE; + virtual void TreeNodeChanged(ui::TreeModel* model, + ui::TreeModelNode* node) OVERRIDE {} + virtual void TreeModelBeginBatch(IntentsModel* model) OVERRIDE; + virtual void TreeModelEndBatch(IntentsModel* model) OVERRIDE; + + private: + // Creates the IntentsModel if neccessary. + void EnsureIntentsModelCreated(); + + // Updates search filter for cookies tree model. + void UpdateSearchResults(const base::ListValue* args); + + // Remove all sites data. + void RemoveAll(const base::ListValue* args); + + // Remove selected sites data. + void RemoveIntent(const base::ListValue* args); + + // Helper functions for removals. + void RemoveOrigin(IntentsTreeNode* node); + void RemoveService(ServiceTreeNode* snode); + + // Trigger for SendChildren to load the JS model. + void LoadChildren(const base::ListValue* args); + + // Get children nodes data and pass it to 'IntentsView.loadChildren' to + // update the WebUI. + void SendChildren(IntentsTreeNode* parent); + + scoped_refptr<WebDataService> web_data_service_; + WebIntentsRegistry* web_intents_registry_; // Weak pointer. + + // Backing data model for the intents list. + scoped_ptr<IntentsModel> intents_tree_model_; + + // Flag to indicate whether there is a batch update in progress. + bool batch_update_; + + DISALLOW_COPY_AND_ASSIGN(IntentsSettingsHandler); +}; + +#endif // CHROME_BROWSER_UI_WEBUI_OPTIONS_INTENTS_SETTINGS_HANDLER_H_ diff --git a/chrome/browser/ui/webui/options/options_ui.cc b/chrome/browser/ui/webui/options/options_ui.cc index 0847472..98e7d8b 100644 --- a/chrome/browser/ui/webui/options/options_ui.cc +++ b/chrome/browser/ui/webui/options/options_ui.cc @@ -28,6 +28,7 @@ #include "chrome/browser/ui/webui/options/core_options_handler.h" #include "chrome/browser/ui/webui/options/font_settings_handler.h" #include "chrome/browser/ui/webui/options/import_data_handler.h" +#include "chrome/browser/ui/webui/options/intents_settings_handler.h" #include "chrome/browser/ui/webui/options/language_options_handler.h" #include "chrome/browser/ui/webui/options/manage_profile_handler.h" #include "chrome/browser/ui/webui/options/options_sync_setup_handler.h" @@ -210,6 +211,7 @@ OptionsUI::OptionsUI(TabContents* contents) AddOptionsPageUIHandler(localized_strings, new ContentSettingsHandler()); AddOptionsPageUIHandler(localized_strings, new CookiesViewHandler()); AddOptionsPageUIHandler(localized_strings, new FontSettingsHandler()); + AddOptionsPageUIHandler(localized_strings, new IntentsSettingsHandler()); #if defined(OS_CHROMEOS) AddOptionsPageUIHandler(localized_strings, new chromeos::CrosLanguageOptionsHandler()); diff --git a/chrome/chrome_browser.gypi b/chrome/chrome_browser.gypi index f2403b2..c935047 100644 --- a/chrome/chrome_browser.gypi +++ b/chrome/chrome_browser.gypi @@ -2942,6 +2942,8 @@ 'browser/ui/input_window_dialog.h', 'browser/ui/input_window_dialog_gtk.cc', 'browser/ui/input_window_dialog_win.cc', + 'browser/ui/intents/intents_model.cc', + 'browser/ui/intents/intents_model.h', 'browser/ui/login/login_model.h', 'browser/ui/login/login_prompt.cc', 'browser/ui/login/login_prompt.h', @@ -3615,6 +3617,8 @@ 'browser/ui/webui/options/handler_options_handler.h', 'browser/ui/webui/options/import_data_handler.cc', 'browser/ui/webui/options/import_data_handler.h', + 'browser/ui/webui/options/intents_settings_handler.cc', + 'browser/ui/webui/options/intents_settings_handler.h', 'browser/ui/webui/options/language_options_handler.cc', 'browser/ui/webui/options/language_options_handler.h', 'browser/ui/webui/options/language_options_handler_common.cc', diff --git a/chrome/chrome_tests.gypi b/chrome/chrome_tests.gypi index 0662b00..ddbcbec 100644 --- a/chrome/chrome_tests.gypi +++ b/chrome/chrome_tests.gypi @@ -1868,6 +1868,7 @@ 'browser/ui/gtk/reload_button_gtk_unittest.cc', 'browser/ui/gtk/status_icons/status_tray_gtk_unittest.cc', 'browser/ui/gtk/tabs/tab_renderer_gtk_unittest.cc', + 'browser/ui/intents/intents_model_unittest.cc', 'browser/ui/login/login_prompt_unittest.cc', 'browser/ui/omnibox/omnibox_view_unittest.cc', 'browser/ui/panels/panel_browser_window_cocoa_unittest.mm', |