blob: b09684e4c2721f5f8ca50ae8b83ffa4ea962fda9 [file] [log] [blame]
// Copyright 2017 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('discards', function() {
'use strict';
// The following variables are initialized by 'initialize'.
// Points to the Mojo WebUI handler.
let uiHandler;
// After initialization this points to the discard info table body.
let tabDiscardsInfoTableBody;
// This holds the sorted tab discard infos as retrieved from the uiHandler.
let infos;
// Holds information about the current sorting of the table.
let sortKey;
let sortReverse;
// Points to the timer that refreshes the table content.
let updateTimer;
// Specifies the update interval of the page, in ms.
const UPDATE_INTERVAL_MS = 1000;
/**
* Ensures the discards info table has the appropriate length. Decorates
* newly created rows with a 'row-index' attribute to enable event listeners
* to quickly determine the index of the row.
*/
function ensureTabDiscardsInfoTableLength() {
let rows = tabDiscardsInfoTableBody.querySelectorAll('tr');
if (rows.length < infos.length) {
for (let i = rows.length; i < infos.length; ++i) {
let row = createEmptyTabDiscardsInfoTableRow();
row.setAttribute('data-row-index', i.toString());
tabDiscardsInfoTableBody.appendChild(row);
}
} else if (rows.length > infos.length) {
for (let i = infos.length; i < rows.length; ++i) {
tabDiscardsInfoTableBody.removeChild(rows[i]);
}
}
}
/**
* Compares two TabDiscardsInfos based on the data in the provided sort-key.
* @param {string} sortKey The key of the sort. See the "data-sort-key"
* attribute of the table headers for valid sort-keys.
* @param {boolean|number|string} a The first value being compared.
* @param {boolean|number|string} b The second value being compared.
* @return {number} A negative number if a < b, 0 if a == b, and a positive
* number if a > b.
*/
function compareTabDiscardsInfos(sortKey, a, b) {
let val1 = a[sortKey];
let val2 = b[sortKey];
// Compares strings.
if (sortKey == 'title' || sortKey == 'tabUrl') {
val1 = val1.toLowerCase();
val2 = val2.toLowerCase();
if (val1 == val2)
return 0;
return val1 > val2 ? 1 : -1;
}
// Compares boolean fields.
if (['isMedia', 'isDiscarded', 'isAutoDiscardable'].includes(sortKey)) {
if (val1 == val2)
return 0;
return val1 ? 1 : -1;
}
// Compares numeric fields.
if (['discardCount', 'utilityRank', 'lastActiveSeconds'].includes(
sortKey)) {
return val1 - val2;
}
assertNotReached('Unsupported sort key: ' + sortKey);
return 0;
}
/**
* Sorts the tab discards info data in |infos| according to the current
* |sortKey|.
*/
function sortTabDiscardsInfoTable() {
infos = infos.sort((a, b) => {
return (sortReverse ? -1 : 1) * compareTabDiscardsInfos(sortKey, a, b);
});
}
/**
* Pluralizes a string according to the given count. Assumes that appending an
* 's' is sufficient to make a string plural.
* @param {string} s The string to be made plural if necessary.
* @param {number} n The count of the number of ojects.
* @return {string} The plural version of |s| if n != 1, otherwise |s|.
*/
function maybeMakePlural(s, n) {
return n == 1 ? s : s + 's';
}
/**
* Converts a |secondsAgo| last-active time to a user friendly string.
* @param {number} secondsAgo The amount of time since the tab was active.
* @return {string} An English string representing the last active time.
*/
function lastActiveToString(secondsAgo) {
// These constants aren't perfect, but close enough.
const SECONDS_PER_MINUTE = 60;
const MINUTES_PER_HOUR = 60;
const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR;
const HOURS_PER_DAY = 24;
const SECONDS_PER_DAY = SECONDS_PER_HOUR * HOURS_PER_DAY;
const DAYS_PER_WEEK = 7;
const SECONDS_PER_WEEK = SECONDS_PER_DAY * DAYS_PER_WEEK;
const SECONDS_PER_MONTH = SECONDS_PER_DAY * 30.5;
const SECONDS_PER_YEAR = SECONDS_PER_DAY * 365;
// Seconds ago.
if (secondsAgo < SECONDS_PER_MINUTE)
return 'just now';
// Minutes ago.
let minutesAgo = Math.floor(secondsAgo / SECONDS_PER_MINUTE);
if (minutesAgo < MINUTES_PER_HOUR) {
return minutesAgo.toString() + maybeMakePlural(' minute', minutesAgo) +
' ago';
}
// Hours and minutes and ago.
let hoursAgo = Math.floor(secondsAgo / SECONDS_PER_HOUR);
minutesAgo = minutesAgo % MINUTES_PER_HOUR;
if (hoursAgo < HOURS_PER_DAY) {
let s = hoursAgo.toString() + maybeMakePlural(' hour', hoursAgo);
if (minutesAgo > 0) {
s += ' and ' + minutesAgo.toString() +
maybeMakePlural(' minute', minutesAgo);
}
s += ' ago';
return s;
}
// Days ago.
let daysAgo = Math.floor(secondsAgo / SECONDS_PER_DAY);
if (daysAgo < DAYS_PER_WEEK) {
return daysAgo.toString() + maybeMakePlural(' day', daysAgo) + ' ago';
}
// Weeks ago. There's an awkward gap to bridge where 4 weeks can have
// elapsed but not quite 1 month. Be sure to use weeks to report that.
let weeksAgo = Math.floor(secondsAgo / SECONDS_PER_WEEK);
let monthsAgo = Math.floor(secondsAgo / SECONDS_PER_MONTH);
if (monthsAgo < 1) {
return 'over ' + weeksAgo.toString() +
maybeMakePlural(' week', weeksAgo) + ' ago';
}
// Months ago.
let yearsAgo = Math.floor(secondsAgo / SECONDS_PER_YEAR);
if (yearsAgo < 1) {
return 'over ' + monthsAgo.toString() +
maybeMakePlural(' month', monthsAgo) + ' ago';
}
// Years ago.
return 'over ' + yearsAgo.toString() + maybeMakePlural(' year', yearsAgo) +
' ago';
}
/**
* Returns a string representation of a boolean value for display in a table.
* @param {boolean} bool A boolean value.
* @return {string} A string representing the bool.
*/
function boolToString(bool) {
return bool ? '✔' : '\xa0';
}
/**
* Returns the index of the row in the table that houses the given |element|.
* @param {HTMLElement} element Any element in the DOM.
*/
function getRowIndex(element) {
let row = element.closest('tr');
return parseInt(row.getAttribute('data-row-index'), 10);
}
/**
* Creates an empty tab discards table row with action-link listeners, etc.
* By default the links are inactive.
*/
function createEmptyTabDiscardsInfoTableRow() {
let template = $('tab-discard-info-row');
let content = document.importNode(template.content, true);
let row = content.querySelector('tr');
// Set up the listener for the auto-discardable toggle action.
let isAutoDiscardable = row.querySelector('.is-auto-discardable-link');
isAutoDiscardable.setAttribute('disabled', '');
isAutoDiscardable.addEventListener('click', (e) => {
// Get the info backing this row.
let info = infos[getRowIndex(e.target)];
// Disable the action. The update function is responsible for
// re-enabling actions if necessary.
e.target.setAttribute('disabled', '');
// Perform the action.
uiHandler.setAutoDiscardable(info.id, !info.isAutoDiscardable)
.then(stableUpdateTabDiscardsInfoTable());
});
// Set up the listeners for discard links.
let discardListener = function(e) {
// Get the info backing this row.
let info = infos[getRowIndex(e.target)];
// Determine whether this is urgent or not.
let urgent = e.target.classList.contains('discard-urgent-link');
// Disable the action. The update function is responsible for
// re-enabling actions if necessary.
e.target.setAttribute('disabled', '');
// Perform the action.
uiHandler.discardById(info.id, urgent).then((response) => {
stableUpdateTabDiscardsInfoTable();
});
};
let discardLink = row.querySelector('.discard-link');
let discardUrgentLink = row.querySelector('.discard-urgent-link');
discardLink.addEventListener('click', discardListener);
discardUrgentLink.addEventListener('click', discardListener);
return row;
}
/**
* Updates a tab discards info table row in place. Sets/unsets 'disabled'
* attributes on action-links as necessary, and populates all contents.
*/
function updateTabDiscardsInfoTableRow(row, info) {
// Update the content.
row.querySelector('.utility-rank-cell').textContent =
info.utilityRank.toString();
row.querySelector('.favicon').src =
info.faviconUrl ? info.faviconUrl : 'chrome://favicon';
row.querySelector('.title-div').textContent = info.title;
row.querySelector('.tab-url-cell').textContent = info.tabUrl;
row.querySelector('.is-media-cell').textContent =
boolToString(info.isMedia);
row.querySelector('.is-discarded-cell').textContent =
boolToString(info.isDiscarded);
row.querySelector('.discard-count-cell').textContent =
info.discardCount.toString();
row.querySelector('.is-auto-discardable-div').textContent =
boolToString(info.isAutoDiscardable);
row.querySelector('.last-active-cell').textContent =
lastActiveToString(info.lastActiveSeconds);
// Enable/disable action links as appropriate.
row.querySelector('.is-auto-discardable-link').removeAttribute('disabled');
let discardLink = row.querySelector('.discard-link');
let discardUrgentLink = row.querySelector('.discard-urgent-link');
if (info.isDiscarded) {
discardLink.setAttribute('disabled', '');
discardUrgentLink.setAttribute('disabled', '');
} else {
discardLink.removeAttribute('disabled');
discardUrgentLink.removeAttribute('disabled');
}
}
/**
* Causes the discards info table to be rendered. Reuses existing table rows
* in place to minimize disruption to the page.
*/
function renderTabDiscardsInfoTable() {
ensureTabDiscardsInfoTableLength();
let rows = tabDiscardsInfoTableBody.querySelectorAll('tr');
for (let i = 0; i < infos.length; ++i)
updateTabDiscardsInfoTableRow(rows[i], infos[i]);
}
/**
* Causes the discard info table to be updated in as stable a manner as
* possible. That is, rows will stay in their relative positions, even if the
* current sort order is violated. Only the addition or removal of rows (tabs)
* can cause the layout to change.
*/
function stableUpdateTabDiscardsInfoTableImpl() {
uiHandler.getTabDiscardsInfo().then((response) => {
let newInfos = response.infos;
let stableInfos = [];
// Update existing infos in place, remove old ones, and append new ones.
// This tries to keep the existing ordering stable so that clicking links
// is minimally disruptive.
for (let i = 0; i < infos.length; ++i) {
let oldInfo = infos[i];
let newInfo = null;
for (let j = 0; j < newInfos.length; ++j) {
if (newInfos[j].id == oldInfo.id) {
newInfo = newInfos[j];
break;
}
}
// Old infos that have corresponding new infos are pushed first, in the
// current order of the old infos.
if (newInfo != null)
stableInfos.push(newInfo);
}
// Make sure info about new tabs is appended to the end, in the order they
// were originally returned.
for (let i = 0; i < newInfos.length; ++i) {
let newInfo = newInfos[i];
let oldInfo = null;
for (let j = 0; j < infos.length; ++j) {
if (infos[j].id == newInfo.id) {
oldInfo = infos[j];
break;
}
}
// Entirely new information (has no corresponding old info) is appended
// to the end.
if (oldInfo == null)
stableInfos.push(newInfo);
}
// Swap out the current info with the new stably sorted information.
infos = stableInfos;
// Render the content in place.
renderTabDiscardsInfoTable();
});
}
/**
* A wrapper to stableUpdateTabDiscardsInfoTableImpl that is called due to
* user action and not due to the automatic timer. Cancels the existing timer
* and reschedules it after rendering instantaneously.
*/
function stableUpdateTabDiscardsInfoTable() {
if (updateTimer)
clearInterval(updateTimer);
stableUpdateTabDiscardsInfoTableImpl();
updateTimer =
setInterval(stableUpdateTabDiscardsInfoTableImpl, UPDATE_INTERVAL_MS);
}
/**
* Initializes this page. Invoked by the DOMContentLoaded event.
*/
function initialize() {
uiHandler = new mojom.DiscardsDetailsProviderPtr;
Mojo.bindInterface(
mojom.DiscardsDetailsProvider.name, mojo.makeRequest(uiHandler).handle);
tabDiscardsInfoTableBody = $('tab-discards-info-table-body');
infos = [];
sortKey = 'utilityRank';
sortReverse = false;
updateTimer = null;
// Set the column sort handlers.
let tabDiscardsInfoTableHeader = $('tab-discards-info-table-header');
let headers = tabDiscardsInfoTableHeader.children;
for (let header of headers) {
header.addEventListener('click', (e) => {
let newSortKey = e.target.dataset.sortKey;
// Skip columns that aren't explicitly labeled with a sort-key
// attribute.
if (newSortKey == null)
return;
// Reverse the sort key if the key itself hasn't changed.
if (sortKey == newSortKey) {
sortReverse = !sortReverse;
} else {
sortKey = newSortKey;
sortReverse = false;
}
// Undecorate the old sort column, and decorate the new one.
let oldSortColumn = document.querySelector('.sort-column');
oldSortColumn.classList.remove('sort-column');
e.target.classList.add('sort-column');
if (sortReverse)
e.target.setAttribute('data-sort-reverse', '');
else
e.target.removeAttribute('data-sort-reverse');
sortTabDiscardsInfoTable();
renderTabDiscardsInfoTable();
});
}
// Setup the "Discard a tab now" links.
let discardNow = $('discard-now-link');
let discardNowUrgent = $('discard-now-urgent-link');
let discardListener = function(e) {
e.target.setAttribute('disabled', '');
let urgent = e.target.id.includes('urgent');
uiHandler.discard(urgent).then(() => {
stableUpdateTabDiscardsInfoTable();
e.target.removeAttribute('disabled');
});
};
discardNow.addEventListener('click', discardListener);
discardNowUrgent.addEventListener('click', discardListener);
stableUpdateTabDiscardsInfoTable();
}
document.addEventListener('DOMContentLoaded', initialize);
// These functions are exposed on the 'discards' object created by
// cr.define. This allows unittesting of these functions.
return {
compareTabDiscardsInfos: compareTabDiscardsInfos,
lastActiveToString: lastActiveToString,
maybeMakePlural: maybeMakePlural
};
});