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;
+ }
+};
+