diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index a1f8f713017d..22dfd4c146af 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -389,6 +389,7 @@ pref("browser.tabs.tabClipWidth", 140); pref("browser.tabs.animate", true); pref("browser.tabs.onTop", true); pref("browser.tabs.drawInTitlebar", true); +pref("browser.tabs.cropTitleRedundancy", true); // Where to show tab close buttons: // 0 on active tab only diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js index f14f649baa94..3baf645c807d 100644 --- a/browser/base/content/browser.js +++ b/browser/base/content/browser.js @@ -157,6 +157,12 @@ XPCOMUtils.defineLazyGetter(this, "gBrowserNewTabPreloader", function () { return new tmp.BrowserNewTabPreloader(); }); +XPCOMUtils.defineLazyGetter(this, "TabTitleAbridger", function() { + let tmp = {}; + Cu.import("resource:///modules/TabTitleAbridger.jsm", tmp); + return new tmp.TabTitleAbridger(window); +}); + let gInitialPages = [ "about:blank", "about:newtab", @@ -1413,6 +1419,7 @@ var gBrowserInit = { gBrowserThumbnails.init(); TabView.init(); + TabTitleAbridger.init(); setUrlAndSearchBarWidthForConditionalForwardButton(); window.addEventListener("resize", function resizeHandler(event) { @@ -1608,6 +1615,7 @@ var gBrowserInit = { TabView.uninit(); gBrowserThumbnails.uninit(); FullZoom.destroy(); + TabTitleAbridger.destroy(); Services.obs.removeObserver(gSessionHistoryObserver, "browser:purge-session-history"); Services.obs.removeObserver(gXPInstallObserver, "addon-install-disabled"); diff --git a/browser/base/content/test/Makefile.in b/browser/base/content/test/Makefile.in index e83ceb3cb501..eedd56660cdf 100644 --- a/browser/base/content/test/Makefile.in +++ b/browser/base/content/test/Makefile.in @@ -134,6 +134,7 @@ _BROWSER_FILES = \ browser_bug581242.js \ browser_bug581253.js \ browser_bug581947.js \ + browser_bug583890.js \ browser_bug583890_label.js \ browser_bug585785.js \ browser_bug585830.js \ diff --git a/browser/base/content/test/browser_bug583890.js b/browser/base/content/test/browser_bug583890.js new file mode 100644 index 000000000000..e64455ead73a --- /dev/null +++ b/browser/base/content/test/browser_bug583890.js @@ -0,0 +1,359 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Call the aCallback when aTab's label is equal to the aExpectedLabel. + * Either happens immediately, or after a series of TabAttrModified events. + * In the case of failure, this will cause timeout of the test. + * + * @param aTab the tab whose label is being tested + * @param aExpectedLabel the value the tab's label must match + * @param aCallback the callback for use upon success + */ +function waitForTabLabel(aTab, aExpectedLabel, aCallback) { + if (aTab.visibleLabel == aExpectedLabel) { + executeSoon(aCallback); + } else { + executeSoon(function () { waitForTabLabel(aTab, aExpectedLabel, aCallback); }); + } +} + +/** + * Call the aCallback after adding aCount tabs. + * + * @param aCount the number of tabs to add + * @param aCallback the callback for use upon success + */ +function addTabs(aCount, aCallback) { + let addedTabs = []; + for (let i = aCount; i > 0; i--) { + addedTabs.push(gBrowser.addTab()); + } + executeSoon(function () { aCallback(addedTabs); }); +} + +/** + * Call the aCallback after updating aTab's title and waiting for a label update + * In the case of failure, this will cause timeout of the test. + * + * @param aTab the tab whose title is set + * @param aTitle the value to give the tab title + * @param aExpectedLabel the value the tab's label must match + * @param aCallback the callback for use upon success + */ +function setTitleForTab(aTab, aTitle, aExpectedLabel, aCallback) { + aTab.linkedBrowser.contentDocument.title = aTitle; + waitForTabLabel(aTab, aExpectedLabel, aCallback); +} + +/** + * Call the aCallback after updating aTab's label and waiting for a label update + * In the case of failure, this will cause timeout of the test. + * + * @param aTab the tab whose title is set + * @param aTitle the value to give the tab title + * @param aExpectedLabel the value the tab's label must match + * @param aCallback the callback for use upon success + */ +function setLabelForTab(aTab, aTitle, aExpectedLabel, aCallback) { + aTab.label = aTitle; + waitForTabLabel(aTab, aExpectedLabel, aCallback); +} + +function GroupTest() { + this.groupNumber = 0; + this.tabs = []; +} + +GroupTest.prototype = { + groups: [ + [ + /* + * Test proxying and suffix protection + */ + [ + "Foo - Bar - Baz", + "Foo - Baz - Baz", + "Foo - Baz - Baz", + "Foo - Baz - Qux" + ], + [ + [ + "Bar - Baz", + "Baz - Baz" + ], + [ + "Bar - Baz", + "Baz - Baz", + "Baz - Baz" + ], + [ + "Bar - Baz", + "Baz", + "Baz", + "Qux" + ] + ] + ], + [ + /* + * Test pathmode + */ + [ + "http://example.com/foo.html", + "http://example.com/foo/bar.html", + "Browse - ftp://example.com/pub/", + "Browse - ftp://example.com/pub/src/" + ], + [ + [ + "foo.html", + "foo/bar.html" + ], + [ + "foo.html", + "foo/bar.html", + "Browse - ftp://example.com/pub/" + ], + [ + "foo.html", + "foo/bar.html", + "pub/", + "src/" + ] + ] + ], + [ + /* + * Test that we don't leave a lone suffix + */ + [ + "'Zilla and the Foxes - Singles - Musical Monkey", + "'Zilla and the Foxes - Biography - Musical Monkey", + "'Zilla and the Foxes - Musical Monkey", + "'Zilla and the Foxes - Interviews - Musical Monkey" + ], + [ + [ + "Singles - Musical Monkey", + "Biography - Musical Monkey" + ], + [ + "Singles - Musical Monkey", + "Biography - Musical Monkey", + "'Zilla and the Foxes - Musical Monkey" + ], + [ + "Singles - Musical Monkey", + "Biography - Musical Monkey", + "'Zilla and the Foxes - Musical Monkey", + "Interviews - Musical Monkey" + ] + ] + ], + /* + * Test short endings for MIN_CHOP + */ + [ + [ + "Foo - Bar - 0", + "Foo - Bar - 0 - extra - 0", + "Foo - Bar - 1", + "Foo - Bar - 2 - extra", + "Foo - Bar - 3" + ], + [ + [ + "Bar - 0", + "0 - extra - 0" + ], + [ + "Bar - 0", + "0 - extra - 0", + "Bar - 1" + ], + [ + "Bar - 0", + "0 - extra - 0", + "Bar - 1", + "2 - extra" + ], + [ + "Bar - 0", + "0 - extra - 0", + "Bar - 1", + "2 - extra", + "Bar - 3" + ] + ] + ], + [ + /* + * Test multiple whitespace + */ + [ + "Foo - Bar - Baz", + "Foo - Bar - Baz", + "Foo - Bar - Baz", + "Foo - Baz - Baz" + ], + [ + [ + "Foo - Bar - Baz", + "Foo - Bar - Baz" + ], + [ + "Foo - Bar - Baz", + "Foo - Bar - Baz", + "Foo - Bar - Baz" + ], + [ + "Bar - Baz", + "Bar - Baz", + "Bar - Baz", + "Baz - Baz" + ] + ] + ] + ], + + /** + * Either proceed with the next group, or finish group tests + */ + nextGroup: function GroupTest_nextGroup() { + while (this.tabs.length) { + gBrowser.removeTab(this.tabs.pop()); + } + if (this.groups.length) { + this.groupNumber++; + [this.labels, this.expectedLabels] = this.groups.shift(); + this.nextTab(); + } else { + runNextTest(); + } + }, + + /** + * Runs tests for existing tabs, and adds the next tab (if group isn't empty) + * If the group is empty, starts the next group + */ + nextTab: function GroupTest_nextTab() { + if (this.tabs.length > 1) { + let ourExpected = this.expectedLabels.shift(); + for (let i = 0; i < this.tabs.length; i++) { + is(this.tabs[i].visibleLabel, ourExpected[i], + "Tab " + this.groupNumber + "." + (i + 1) + " has correct visibleLabel"); + } + } + if (this.labels.length) { + this.tabs.push(gBrowser.addTab( + "data:text/html," + this.labels.shift() + "")); + if (this.tabs.length > 1) { + waitForTabLabel(this.tabs[this.tabs.length - 1], + this.expectedLabels[0][this.expectedLabels[0].length - 1], + this.nextTab.bind(this)); + } else { + this.nextTab(); + } + } else { + this.nextGroup(); + } + } +}; + +let TESTS = [ +function test_about_blank() { + let tab1 = gBrowser.selectedTab; + let tab2; + let tab3; + addTabs(2, setup1); + function setup1(aTabs) { + [tab2, tab3] = aTabs + waitForTabLabel(tab3, "New Tab", setupComplete); + } + function setupComplete() { + is(tab1.visibleLabel, "New Tab", "First tab has original label"); + is(tab2.visibleLabel, "New Tab", "Second tab has original label"); + is(tab3.visibleLabel, "New Tab", "Third tab has original label"); + runNextTest(); + } +}, + +function test_two_tabs() { + let tab1 = gBrowser.selectedTab; + addTabs(1, setup1); + let tab2; + function setup1(aTabs) { + tab2 = aTabs[0]; + setTitleForTab(tab1, "Foo - Bar - Baz", "Foo - Bar - Baz", setup2); + } + function setup2() { + setTitleForTab(tab2, "Foo - Baz - Baz", "Baz - Baz", setupComplete); + } + function setupComplete() { + is(tab1.visibleLabel, "Bar - Baz", "Removed exactly two tokens"); + is(tab2.visibleLabel, "Baz - Baz", "Removed exactly two tokens"); + gBrowser.removeTab(tab2); + waitForTabLabel(tab1, "Foo - Bar - Baz", afterRemoval); + } + function afterRemoval() { + is (tab1.visibleLabel, "Foo - Bar - Baz", "Single tab has full title"); + runNextTest(); + } +}, + +function test_direct_label() { + let tab1 = gBrowser.selectedTab; + addTabs(2, setup1); + let tab2; + let tab3; + function setup1(aTabs) { + tab2 = aTabs[0]; + tab3 = aTabs[1]; + setLabelForTab(tab1, "Foo - Bar - Baz", "Foo - Bar - Baz", setup2); + } + function setup2() { + setLabelForTab(tab2, "Foo - Baz - Baz", "Foo - Baz - Baz", setup3); + } + function setup3() { + setLabelForTab(tab3, "Foo - Baz - Baz", "Baz - Baz", setupComplete); + } + function setupComplete() { + is(tab1.visibleLabel, "Bar - Baz", "Removed exactly two tokens"); + is(tab2.visibleLabel, "Foo - Baz - Baz", "Irregular spaces mean no match"); + is(tab3.visibleLabel, "Baz - Baz", "Removed exactly two tokens"); + gBrowser.removeTab(tab3); + waitForTabLabel(tab1, "Foo - Bar - Baz", afterRemoval); + } + function afterRemoval() { + is (tab1.visibleLabel, "Foo - Bar - Baz", "Single tab has full title"); + gBrowser.removeTab(tab2); + runNextTest(); + } +}, + +function test_groups() { + let g = new GroupTest(); + g.nextGroup(); +} +]; + +function runNextTest() { + if (TESTS.length == 0) { + finish(); + return; + } + + while (gBrowser.tabs.length > 1) { + gBrowser.removeTab(gBrowser.tabs[1]); + } + + info("Running " + TESTS[0].name); + TESTS.shift()(); +}; + +function test() { + waitForExplicitFinish(); + runNextTest(); +} + diff --git a/browser/modules/Makefile.in b/browser/modules/Makefile.in index d820ccb8b84e..46ffaee49771 100644 --- a/browser/modules/Makefile.in +++ b/browser/modules/Makefile.in @@ -20,6 +20,7 @@ EXTRA_JS_MODULES = \ NewTabUtils.jsm \ offlineAppCache.jsm \ SignInToWebsite.jsm \ + TabTitleAbridger.jsm \ TelemetryTimestamps.jsm \ Social.jsm \ webappsUI.jsm \ diff --git a/browser/modules/TabTitleAbridger.jsm b/browser/modules/TabTitleAbridger.jsm new file mode 100644 index 000000000000..a5ffaa31496b --- /dev/null +++ b/browser/modules/TabTitleAbridger.jsm @@ -0,0 +1,604 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +let EXPORTED_SYMBOLS = ["TabTitleAbridger"]; + +const Cu = Components.utils; +const ABRIDGMENT_PREF = "browser.tabs.cropTitleRedundancy"; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "gETLDService", + "@mozilla.org/network/effective-tld-service;1", + "nsIEffectiveTLDService"); + +function TabTitleAbridger(aBrowserWin) { + this._tabbrowser = aBrowserWin.gBrowser; +} + +TabTitleAbridger.prototype = { + /* + * Events we listen to. We specifically do not listen for TabCreate, as we + * get TabLabelModified at the appropriate times. + */ + _eventNames: [ + "TabPinned", + "TabUnpinned", + "TabShow", + "TabHide", + "TabClose", + "TabLabelModified" + ], + + init: function TabTitleAbridger_Initialize() { + this._cropTitleRedundancy = Services.prefs.getBoolPref(ABRIDGMENT_PREF); + Services.prefs.addObserver(ABRIDGMENT_PREF, this, false); + if (this._cropTitleRedundancy) { + this._domainSets = new DomainSets(); + this._addListeners(); + } + }, + + destroy: function TabTitleAbridger_Destroy() { + Services.prefs.removeObserver(ABRIDGMENT_PREF, this); + if (this._cropTitleRedundancy) { + this._dropListeners(); + } + }, + + /** + * Preference observer + */ + observe: function TabTitleAbridger_PrefObserver(aSubject, aTopic, aData) { + let val = Services.prefs.getBoolPref(aData); + if (this._cropTitleRedundancy && !val) { + this._dropListeners(); + this._domainSets.destroy(); + delete this._domainSets; + this._resetTabTitles(); + } else if (!this._cropTitleRedundancy && val) { + this._addListeners(); + // We're just turned on, so we want to abridge everything + this._domainSets = new DomainSets(); + let domains = this._domainSets.bootstrap(this._tabbrowser.visibleTabs); + this._abridgeTabTitles(domains); + } + this._cropTitleRedundancy = val; + }, + + /** + * Adds all the necessary event listeners and listener-supporting objects for + * the instance. + */ + _addListeners: function TabTitleAbridger_addListeners() { + let tabContainer = this._tabbrowser.tabContainer; + for (let eventName of this._eventNames) { + tabContainer.addEventListener(eventName, this, false); + } + }, + + /** + * Removes event listeners and listener-supporting objects for the instance. + */ + _dropListeners: function TabTitleAbridger_dropListeners() { + let tabContainer = this._tabbrowser.tabContainer; + for (let eventName of this._eventNames) { + tabContainer.removeEventListener(eventName, this, false); + } + }, + + handleEvent: function TabTitleAbridger_handler(aEvent) { + let tab = aEvent.target; + let updateSets; + + switch (aEvent.type) { + case "TabUnpinned": + case "TabShow": + updateSets = this._domainSets.addTab(tab); + break; + case "TabPinned": + case "TabHide": + case "TabClose": + updateSets = this._domainSets.removeTab(tab); + tab.visibleLabel = tab.label; + break; + case "TabLabelModified": + if (!tab.hidden && !tab.pinned) { + aEvent.preventDefault(); + updateSets = this._domainSets.updateTab(tab); + } + break; + } + this._abridgeTabTitles(updateSets); + }, + + /** + * Make all tabs have their visibleLabels be their labels. + */ + _resetTabTitles: function TabTitleAbridger_resetTabTitles() { + // We're freshly disabled, so reset unpinned, visible tabs (see handleEvent) + for (let tab of this._tabbrowser.visibleTabs) { + if (!tab.pinned && tab.visibleLabel != tab.label) { + tab.visibleLabel = tab.label; + } + } + }, + + /** + * Apply abridgment for the given tabset and chop list. + * @param aTabSet Array of tabs to abridge + * @param aChopList Corresponding array of chop points for the tabs + */ + _applyAbridgment: function TabTitleAbridger_applyAbridgment(aTabSet, + aChopList) { + for (let i = 0; i < aTabSet.length; i++) { + let tab = aTabSet[i]; + let label = tab.label || ""; + if (label.length > 0) { + let chop = aChopList[i] || 0; + if (chop > 0) { + label = label.substr(chop); + } + } + if (label != tab.visibleLabel) { + tab.visibleLabel = label; + } + } + }, + + /** + * Abridges the tabs sets of tabs in the aTabSets array. + * @param aTabSets Array of tab sets needing abridgment + */ + _abridgeTabTitles: function TabTitleAbridger_abridgeTabtitles(aTabSets) { + // Process each set + for (let tabSet of aTabSets) { + // Get a chop list for the set and apply it + let chopList = AbridgmentTools.getChopsForSet(tabSet); + this._applyAbridgment(tabSet, chopList); + } + } +}; + +/** + * Maintains a mapping between tabs and domains, so that only the tabs involved + * in a TabLabelModified event need to be modified by the TabTitleAbridger. + */ +function DomainSets() { + this._domainSets = {}; + this._tabsMappedToDomains = new WeakMap(); +} + +DomainSets.prototype = { + _noHostSchemes: { + chrome: true, + file: true, + resource: true, + data: true, + about: true + }, + + destroy: function DomainSets_destroy() { + delete this._domainSets; + delete this._tabsMappedToDomains; + }, + + /** + * Used to build the domainsets when enabled in mid-air, as opposed to when + * the window is coming up. + * @param The visibleTabs for the browser, or a set of tabs to check. + * @return An array containing the tabs in the domains they belong to, or + * an empty array if none of the tabs belonged to domains. + */ + bootstrap: function DomainSets_bootstrap(aVisibleTabs) { + let needAbridgment = []; + for (let tab of aVisibleTabs) { + let domainSet = this.addTab(aTab)[0] || null; + if (domainSet && needAbridgment.indexOf(domainSet) == -1) { + needAbridgment.push(domainSet); + } + } + return needAbridgment; + }, + + /** + * Given a tab, include it in the domain sets. + * @param aTab The tab to include in the domain sets + * @param aTabDomain [optional] The known domain for the tab + * @return An array containing the tabs in the domain the tab was added to. + */ + addTab: function DomainSets_addTab(aTab, aTabDomain) { + let tabDomain = aTabDomain || this._getDomainForTab(aTab); + if (!this._domainSets.hasOwnProperty(tabDomain)) { + this._domainSets[tabDomain] = []; + } + this._domainSets[tabDomain].push(aTab); + this._tabsMappedToDomains.set(aTab, tabDomain); + return [this._domainSets[tabDomain]]; + }, + + /** + * Given a tab, remove it from the domain sets. + * @param aTab The tab to remove from the domain sets + * @param aTabDomain [optional] The known domain for the tab + * @return An array containing the tabs in the domain the tab was removed + * from, or an empty array if the tab was not removed from a domain set. + */ + removeTab: function DomainSets_removeTab(aTab, aTabDomain) { + let oldTabDomain = aTabDomain || this._tabsMappedToDomains.get(aTab); + if (!this._domainSets.hasOwnProperty(oldTabDomain)) { + return []; + } + let index = this._domainSets[oldTabDomain].indexOf(aTab); + if (index == -1) { + return []; + } + this._domainSets[oldTabDomain].splice(index, 1); + this._tabsMappedToDomains.delete(aTab); + if (!this._domainSets[oldTabDomain].length) { + // Keep the sets clean of empty domains + delete this._domainSets[oldTabDomain]; + return []; + } + return [this._domainSets[oldTabDomain]]; + }, + + /** + * Given a tab, update the domain set it belongs to. + * @param aTab The tab to update the domain set for + * @return An array containing the tabs in the domain the tab belongs to, and + * (if changed) the domain the tab was removed from. + */ + updateTab: function DomainSets_updateTab(aTab) { + let tabDomain = this._getDomainForTab(aTab); + let oldTabDomain = this._tabsMappedToDomains.get(aTab); + if (oldTabDomain != tabDomain) { + let needAbridgment = []; + // Probably swapping domain sets out; we pass the domains along to avoid + // re-getting them in addTab/removeTab + if (oldTabDomain) { + needAbridgment = needAbridgment.concat( + this.removeTab(aTab, oldTabDomain)); + } + return needAbridgment.concat(this.addTab(aTab, tabDomain)); + } + // No change was needed + return [this._domainSets[tabDomain]]; + }, + + /** + * Given a tab, determine the URI scheme or host to categorize it. + * @param aTab The tab to get the domain for + * @return The domain or scheme for the tab + */ + _getDomainForTab: function DomainSets_getDomainForTab(aTab) { + let browserURI = aTab.linkedBrowser.currentURI; + if (browserURI.scheme in this._noHostSchemes) { + return browserURI.scheme; + } + + // throws for empty URI, host is IP, and disallowed characters + try { + return gETLDService.getBaseDomain(browserURI); + } + catch (e) {} + + // this nsIURI may not be an nsStandardURL nsIURI, which means it + // might throw for the host + try { + return browserURI.host; + } + catch (e) {} + + // Treat this URI as unique + return browserURI.spec; + } +}; + +let AbridgmentTools = { + /** + * Constant for the minimum remaining length allowed if a label is abridged. + * I.e., original:"abc - de" might be chopped to just "de", which is too + * small, so the label would be reverted to the next-longest version. + */ + MIN_CHOP: 3, + + /** + * Helper to determine if aStr is URI-like + * \s? optional leading space + * [^\s\/]* optional scheme or relative path component + * ([^\s\/]+:\/)? optional scheme separator, with at least one scheme char + * \/ at least one slash + * \/? optional second (or third for eg, file scheme on UNIX) slash + * [^\s\/]* optional path component + * ([^\s\/]+\/?)* optional more path components with optional end slash + * @param aStr the string to check for URI-likeness + * @return boolean value of whether aStr matches + */ + _titleIsURI: function AbridgmentTools_titleIsURI(aStr) { + return /^\s?[^\s\/]*([^\s\/]+:\/)?\/\/?[^\s\/]*([^\s\/]+\/?)*$/.test(aStr); + }, + + /** + * Finds the proper abridgment indexes for the given tabs. + * @param aTabSet the array of tabs to find abridgments for + * @return an array of abridgment indexes corresponding to the tabs + */ + getChopsForSet: function AbridgmentTools_getChopsForSet(aTabSet) { + let chopList = []; + let pathMode = false; + + aTabSet.sort(function(aTab, bTab) { + let aLabel = aTab.label; + let bLabel = bTab.label; + return (aLabel < bLabel) ? -1 : (aLabel > bLabel) ? 1 : 0; + }); + + // build and apply the chopList for the set + for (let i = 0, next = 1; next < aTabSet.length; i = next++) { + next = this._abridgePair(aTabSet, i, next, chopList); + } + return chopList; + }, + + /** + * Handles the abridgment between aIndex and aNext, or in the case where the + * label at aNext is the same as at aIndex, moves aNext forward appropriately. + * @param aTabSet Sorted array of tabs that the indices refer to + * @param aIndex First tab index to use in abridgment + * @param aNext Second tab index to use as the an initial comparison + * @param aChopList Array to add chop points to for the given tabs + * @return Index to replace aNext with, that is the index of the tab that was + * used in abridging the tab at aIndex + */ + _abridgePair: function TabTitleAbridger_abridgePair(aTabSet, aIndex, aNext, + aChopList) { + let tabStr = aTabSet[aIndex].label; + let pathMode = this._titleIsURI(tabStr); + let chop = RedundancyFinder.indexOfSep(pathMode, tabStr); + + // Default to no chop + if (!aChopList[aIndex]) { + aChopList[aIndex] = 0; + } + + // Siblings with same label get proxied by the first + let nextStr; + aNext = this._nextUnproxied(aTabSet, tabStr, aNext); + if (aNext < aTabSet.length) { + nextStr = aTabSet[aNext].label; + } + + // Bail on these strings early, using the first as the basis + if (chop == -1 || aNext == aTabSet.length || + !nextStr.startsWith(tabStr.substr(0, chop + 1))) { + chop = aChopList[aIndex]; + if (aNext != aTabSet.length) { + aChopList[aNext] = 0; + } + } else { + [pathMode, chop] = this._getCommonChopPoint(pathMode, tabStr, nextStr, + chop); + [chop, aChopList[aNext]] = this._adjustChops(pathMode, tabStr, nextStr, + chop); + aChopList[aIndex] = chop; + } + + // Mark chop on the relevant tabs + for (let j = aIndex; j < aNext; j++) { + let oldChop = aChopList[j]; + if (!oldChop || oldChop < chop) { + aChopList[j] = chop; + } + } + return aNext; + }, + + /** + * Gets the index in aTabSet of the next tab that's not equal to aStr. + * @param aTabSet Sorted set of tabs to check + * @param aStr Label string to check against + * @param aStart First item to check for proxying + * @return The index of the next different tab. + */ + _nextUnproxied: function AbridgmentTools_nextUnproxied(aTabSet, aTabStr, + aStart) { + let nextStr = aTabSet[aStart].label; + while (aStart < aTabSet.length && aTabStr == nextStr) { + aStart += 1; + if (aStart < aTabSet.length) { + nextStr = aTabSet[aStart].label; + } + } + return aStart; + }, + + /** + * Get the common index where the aTabStr and aNextStr diverge. + * @param aPathMode Whether to use path mode + * @param aTabStr Tab label + * @param aNextStr Second tab label + * @param aChop Current chop point being considered (index of aTabStr's + * first separator) + * @return An array containing the resulting path mode (in case it changes) + * and the diverence index for the labels. + */ + _getCommonChopPoint: function AbridgmentTools_getCommonChopPoint(aPathMode, + aTabStr, + aNextStr, + aChop) { + aChop = RedundancyFinder.findCommonPrefix(aPathMode, aTabStr, aNextStr, + aChop); + // Does a URI remain? + if (!aPathMode) { + aPathMode = this._titleIsURI(aTabStr.substr(aChop)); + if (aPathMode) { + aChop = RedundancyFinder.findCommonPrefix(aPathMode, aTabStr, aNextStr, + aChop); + } + } + + return [aPathMode, aChop + 1]; + }, + + /** + * Adjusts the chop points based on their suffixes and lengths. + * @param aPathMode Whether to use path mode + * @param aTabStr Tab label + * @param aNextStr Second tab label + * @param aChop Current chop point being considered + * @return An array containing the chop point for the two labels. + */ + _adjustChops: function AbridgmentTools_adjustChops(aPathMode, aTabStr, + aNextStr, aChop) { + let suffix = RedundancyFinder.findCommonSuffix(aPathMode, aTabStr, + aNextStr); + let sufPos = aTabStr.length - suffix; + let nextSufPos = aNextStr.length - suffix; + let nextChop = aChop; + + // Adjust the chop based on the suffix. + if (sufPos < aChop) { + // Only revert based on suffix for tab and any identicals + aChop = RedundancyFinder.lastIndexOfSep(aPathMode, aTabStr, + sufPos - 1)[1] + 1; + } else if (nextSufPos < aChop) { + // Only revert based on suffix for 'next' + nextChop = RedundancyFinder.lastIndexOfSep(aPathMode, aNextStr, + nextSufPos - 1)[1] + 1; + } + + if (aTabStr.length - aChop < this.MIN_CHOP) { + aChop = RedundancyFinder.lastIndexOfSep(aPathMode, aTabStr, + aChop - 2)[1] + 1; + } + if (aNextStr.length - nextChop < this.MIN_CHOP) { + nextChop = RedundancyFinder.lastIndexOfSep(aPathMode, aNextStr, + nextChop - 2)[1] + 1; + } + return [aChop, nextChop]; + } +}; + +let RedundancyFinder = { + /** + * Finds the first index of a matched separator after aStart. + * Separators will either be space-padded punctuation or slashes (in pathmode) + * + * ^.+? at least one character, non-greedy match + * \s+ one or more whitespace characters + * [-:>\|]+ one or more separator characters + * \s+ one or more whitespace characters + * + * @param aPathMode true for path mode, false otherwise + * @param aStr the string to look for a separator in + * @param aStart (optional) an index to start the search from + * @return the next index of a separator or -1 for none + */ + indexOfSep: function RedundancyFinder_indexOfSep(aPathMode, aStr, aStart) { + if (aPathMode) { + return aStr.indexOf('/', aStart); + } + + let match = aStr.slice(aStart).match(/^.+?\s+[-:>\|]+\s+/); + if (match) { + return (aStart || 0) + match[0].length - 1; + } + + return -1; + }, + + /** + * Compares a pair of strings, seeking an index where their redundancy ends + * @param aPathMode true for pathmode, false otherwise + * @param aStr the string to decide an abridgment for + * @param aNextStr the lexicographically next string to compare with + * @param aChop the basis index, a best-known index to begin comparison + * @return the index at which aStr's abridged title should begin + */ + findCommonPrefix: function RedundancyFinder_findCommonPrefix(aPathMode, aStr, + aNextStr, + aChop) { + // Advance until the end of the title or the pair diverges + do { + aChop = this.indexOfSep(aPathMode, aStr, aChop + 1); + } while (aChop != -1 && aNextStr.startsWith(aStr.substr(0, aChop + 1))); + + if (aChop < 0) { + aChop = aStr.length; + } + + // Return the last valid spot + return this.lastIndexOfSep(aPathMode, aStr, aChop - 1)[1]; + }, + + /** + * Finds the range of a separator earlier than aEnd in aStr + * The range is required by findCommonSuffix() needing to know the beginning + * of the separator. + * Separators will either be space-padded punctuation or slashes (in pathmode) + * + * .+ one or more initial characters + * ( first group + * ( second group + * \s+ one or more whitespace characters + * [-:>\|]+ one or more separator characters + * \s+ one or more whitespace characters + * ) end first group + * .*? zero or more characters, non-greedy match + * ) end second group + * $ end of input + * + * @param aPathMode true for pathmode, false otherwise + * @param aStr the string to look for a separator in + * @param aEnd (optional) an index to start the backwards search from + * @return an array containing the endpoints of a separator (-1, -1 for none) + */ + lastIndexOfSep: function RedundancyFinder_lastIndexOfSep(aPathMode, aStr, + aEnd) { + if (aPathMode) { + let path = aStr.lastIndexOf('/', aEnd); + return [path, path]; + } + + let string = aStr.slice(0, aEnd); + let match = string.match(/.+((\s+[-:>\|]+\s+).*?)$/); + if (match) { + let index = string.length - match[1].length; + return [index, index + match[2].length - 1]; + } + + return [-1, -1]; + }, + + /** + * Finds a common suffix (redundancy at the end of) a pair of strings. + * @param aPathMode true for pathmode, false otherwise + * @param aStr a base string to look for a suffix in + * @param aNextStr a string that may share a common suffix with aStr + * @return an index indicating the divergence between the strings + */ + findCommonSuffix: function RedundancyFinder_findCommonSuffix(aPathMode, aStr, + aNextStr) { + let last = this.lastIndexOfSep(aPathMode, aStr)[0]; + + // Is there any suffix match? + if (!aNextStr.endsWith(aStr.slice(last))) { + return 0; + } + + // Move backwards on the main string until the suffix diverges + let oldLast; + do { + oldLast = last; + last = this.lastIndexOfSep(aPathMode, aStr, last - 1)[0]; + } while (last != -1 && aNextStr.endsWith(aStr.slice(last))); + + return aStr.length - oldLast; + } +}; +