mirror of
https://github.com/zen-browser/desktop.git
synced 2026-05-25 22:38:32 +00:00
1473 lines
40 KiB
JavaScript
1473 lines
40 KiB
JavaScript
/*
|
|
* 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/.
|
|
*/
|
|
|
|
import { html } from "chrome://global/content/vendor/lit.all.mjs";
|
|
import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
|
|
DownloadsCommon:
|
|
"moz-src:///browser/components/downloads/DownloadsCommon.sys.mjs",
|
|
DownloadsViewUI:
|
|
"moz-src:///browser/components/downloads/DownloadsViewUI.sys.mjs",
|
|
DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
|
|
FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
|
|
PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
|
|
});
|
|
|
|
ChromeUtils.defineLazyGetter(
|
|
lazy,
|
|
"l10n",
|
|
() => new Localization(["browser/zen-library.ftl"], true)
|
|
);
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "clipboardHelper", () =>
|
|
Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper)
|
|
);
|
|
|
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
const DAY_MS = 86_400_000;
|
|
|
|
const HISTORY_FILTERS = [
|
|
{ id: "all", label: "library-history-filter-all", days: null },
|
|
{ id: "today", label: "library-history-filter-today", days: 0 },
|
|
{ id: "yesterday", label: "library-history-filter-yesterday", days: 1 },
|
|
{ id: "last-7-days", label: "library-history-filter-last-7-days", days: 7 },
|
|
{
|
|
id: "last-30-days",
|
|
label: "library-history-filter-last-30-days",
|
|
days: 30,
|
|
},
|
|
];
|
|
|
|
const DOWNLOAD_FILTERS = [
|
|
{ id: "all", label: "library-downloads-filter-all" },
|
|
{ id: "completed", label: "library-downloads-filter-completed" },
|
|
{ id: "in-progress", label: "library-downloads-filter-in-progress" },
|
|
{ id: "failed", label: "library-downloads-filter-failed" },
|
|
{ id: "paused", label: "library-downloads-filter-paused" },
|
|
];
|
|
|
|
// Intl singletons (construction is expensive in SpiderMonkey)
|
|
const TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
|
|
timeStyle: "short",
|
|
});
|
|
const WEEKDAY_FORMATTER = new Intl.DateTimeFormat(undefined, {
|
|
weekday: "long",
|
|
});
|
|
const LONG_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: "long",
|
|
});
|
|
|
|
/**
|
|
* Base for all library section custom elements.
|
|
* Subclasses must define static `id` and `label`.
|
|
*/
|
|
class LibrarySection extends MozLitElement {
|
|
static largeContent = false;
|
|
|
|
static get id() {
|
|
throw new Error("LibrarySection subclass must define a static `id`.");
|
|
}
|
|
|
|
static get label() {
|
|
throw new Error("LibrarySection subclass must define a static `label`.");
|
|
}
|
|
|
|
_l10n(key) {
|
|
return lazy.l10n.formatValueSync(key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Base class for sections with a search bar and scrollable results list.
|
|
* Subclasses implement renderSearchResults() and override _onSearch().
|
|
*/
|
|
class SearchSection extends LibrarySection {
|
|
static properties = {
|
|
searchTerm: { type: String },
|
|
inputValue: { type: String },
|
|
isEmpty: { type: Boolean },
|
|
isScrolledToTop: { type: Boolean },
|
|
activeFilter: { type: String },
|
|
};
|
|
|
|
#canDebug = false;
|
|
#searchTimer = null;
|
|
|
|
connectedCallback() {
|
|
// don't wipe a live search term if re-inserted by a parent re-render.
|
|
if (!this.searchTerm) {
|
|
this.searchTerm = "";
|
|
}
|
|
if (this.inputValue === undefined) {
|
|
this.inputValue = this.searchTerm;
|
|
}
|
|
if (!this.activeFilter) {
|
|
this.activeFilter = this._filters()[0]?.id ?? "";
|
|
}
|
|
this.isScrolledToTop = true;
|
|
this.isEmpty = false;
|
|
super.connectedCallback();
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
"#canDebug",
|
|
"zen.library.debug",
|
|
false
|
|
);
|
|
}
|
|
|
|
log(...args) {
|
|
if (this.#canDebug) {
|
|
/* eslint-disable no-console */
|
|
console.debug(`[ZenLibrary/${this.constructor.id}]:`, ...args);
|
|
/* eslint-enable no-console */
|
|
}
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
clearTimeout(this.#searchTimer);
|
|
this.#searchTimer = null;
|
|
}
|
|
|
|
_onSearchInput(event) {
|
|
this.inputValue = event.target.value;
|
|
clearTimeout(this.#searchTimer);
|
|
this.#searchTimer = setTimeout(() => {
|
|
this.#searchTimer = null;
|
|
this.searchTerm = this.inputValue;
|
|
this._onSearch(this.searchTerm);
|
|
}, SEARCH_DEBOUNCE_MS);
|
|
}
|
|
|
|
_onClearSearch() {
|
|
clearTimeout(this.#searchTimer);
|
|
this.#searchTimer = null;
|
|
this.searchTerm = "";
|
|
this.inputValue = "";
|
|
this.renderRoot.querySelector("#zen-library-search-input")?.focus();
|
|
this._onSearch("");
|
|
}
|
|
|
|
_onSearch(_term) {}
|
|
|
|
_dayLabel(date) {
|
|
const d = date instanceof Date ? date : new Date(date);
|
|
const todayStart = new Date().setHours(0, 0, 0, 0);
|
|
const dayStart = new Date(d).setHours(0, 0, 0, 0);
|
|
const diffDays = Math.round((todayStart - dayStart) / 86_400_000);
|
|
if (diffDays === 0) {
|
|
return this._l10n("library-history-today");
|
|
}
|
|
if (diffDays === 1) {
|
|
return this._l10n("library-history-yesterday");
|
|
}
|
|
if (diffDays < 7) {
|
|
return WEEKDAY_FORMATTER.format(d);
|
|
}
|
|
return LONG_DATE_FORMATTER.format(d);
|
|
}
|
|
|
|
get _searchPlaceholder() {
|
|
return "library-search-placeholder";
|
|
}
|
|
|
|
_formatTime(date) {
|
|
if (!date) {
|
|
return "";
|
|
}
|
|
const d = date instanceof Date ? date : new Date(date);
|
|
return TIME_FORMATTER.format(d);
|
|
}
|
|
|
|
/** Subclasses override to expose filter options. Empty array hides the button. */
|
|
_filters() {
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Called when the user picks a filter; subclasses re-query or re-filter.
|
|
*
|
|
* @param _id
|
|
*/
|
|
_onFilterChange(_id) {}
|
|
|
|
_onFilterButtonClick(event) {
|
|
event.stopPropagation();
|
|
const filters = this._filters();
|
|
if (filters.length === 0) {
|
|
return;
|
|
}
|
|
const items = filters.map(f => ({
|
|
l10nId: f.label,
|
|
type: "radio",
|
|
checked: this.activeFilter === f.id,
|
|
onClick: () => this._onSelectFilter(f.id),
|
|
}));
|
|
this._openNativeContextMenu(items, {
|
|
type: "anchor",
|
|
el: event.currentTarget,
|
|
});
|
|
}
|
|
|
|
_onSelectFilter(id) {
|
|
if (id === this.activeFilter) {
|
|
return;
|
|
}
|
|
this.activeFilter = id;
|
|
this._onFilterChange(id);
|
|
}
|
|
|
|
_activeFilterLabel() {
|
|
const filters = this._filters();
|
|
const active = filters.find(f => f.id === this.activeFilter) ?? filters[0];
|
|
return active ? this._l10n(active.label) : "";
|
|
}
|
|
|
|
/**
|
|
* Renders one item row. `sideTop`/`sideBottom` are the stacked right-side
|
|
* info slots (e.g. domain over time for downloads, just time for history).
|
|
*
|
|
* @param root0
|
|
* @param root0.key
|
|
* @param root0.iconSrc
|
|
* @param root0.label
|
|
* @param root0.sublabel
|
|
* @param root0.sideTop
|
|
* @param root0.sideBottom
|
|
* @param root0.payload
|
|
*/
|
|
_renderItem({ key, iconSrc, label, sublabel, sideTop, sideBottom, payload }) {
|
|
return html`
|
|
<div
|
|
class="library-item"
|
|
data-key=${key ?? ""}
|
|
@click=${e => this._onItemClick(e, payload)}
|
|
@auxclick=${e => this._onItemClick(e, payload)}
|
|
@contextmenu=${e => this._onItemContextMenu(e, payload)}
|
|
>
|
|
<div class="library-item-stack">
|
|
<div class="library-item-background"></div>
|
|
<div class="library-item-content">
|
|
<div class="library-item-icon-stack">
|
|
<img
|
|
class="library-item-icon-image"
|
|
src=${iconSrc || ""}
|
|
alt=""
|
|
role="presentation"
|
|
/>
|
|
</div>
|
|
<div class="library-item-label-container">
|
|
<label class="library-item-label">${label}</label>
|
|
<label class="library-item-sublabel">${sublabel ?? ""}</label>
|
|
</div>
|
|
${
|
|
sideTop || sideBottom
|
|
? html`
|
|
<div class="library-item-side">
|
|
${
|
|
sideTop
|
|
? html`<label class="library-item-side-top"
|
|
>${sideTop}</label
|
|
>`
|
|
: ""
|
|
}
|
|
${
|
|
sideBottom
|
|
? html`<label class="library-item-side-bottom"
|
|
>${sideBottom}</label
|
|
>`
|
|
: ""
|
|
}
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
<div class="library-item-actions">
|
|
${this._renderItemActions(payload).map(
|
|
action => html`
|
|
<button
|
|
class="library-item-action-button"
|
|
tabindex="-1"
|
|
data-l10n-id=${action.l10nId ?? ""}
|
|
@click=${e => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
action.onClick?.(e);
|
|
}}
|
|
@auxclick=${e => e.stopPropagation()}
|
|
>
|
|
<img src=${action.icon} alt="" />
|
|
</button>
|
|
`
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Subclasses override to provide trailing action buttons. Default is a single
|
|
* "more options" button that opens the context menu.
|
|
*
|
|
* @param payload
|
|
*/
|
|
_renderItemActions(payload) {
|
|
return [
|
|
{
|
|
icon: "chrome://browser/skin/zen-icons/menu.svg",
|
|
onClick: e => this._onItemMenuButtonClick(e, payload),
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Subclasses override; default is a no-op.
|
|
*
|
|
* @param _event
|
|
* @param _payload
|
|
*/
|
|
_onItemClick(_event, _payload) {}
|
|
|
|
/**
|
|
* Subclasses override to build a menu; return null to skip the menu.
|
|
*
|
|
* @param _payload
|
|
*/
|
|
_buildContextMenu(_payload) {
|
|
return null;
|
|
}
|
|
|
|
_onItemContextMenu(event, payload) {
|
|
const items = this._buildContextMenu(payload);
|
|
if (!items?.length) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this._openNativeContextMenu(items, {
|
|
type: "screen",
|
|
x: event.screenX,
|
|
y: event.screenY,
|
|
});
|
|
}
|
|
|
|
_onItemMenuButtonClick(event, payload) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
const items = this._buildContextMenu(payload);
|
|
if (!items?.length) {
|
|
return;
|
|
}
|
|
this._openNativeContextMenu(items, {
|
|
type: "anchor",
|
|
el: event.currentTarget,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build a native XUL menupopup, append it to the chrome popup-set, open at
|
|
* the requested location, and remove it on hide. We rebuild on every open
|
|
* because the action set depends on the row's payload.
|
|
*
|
|
* @param items
|
|
* @param opener
|
|
*/
|
|
_openNativeContextMenu(items, opener) {
|
|
const popupSet = document.getElementById("mainPopupSet");
|
|
const popup = document.createXULElement("menupopup");
|
|
|
|
for (const item of items) {
|
|
if (item.separator) {
|
|
popup.appendChild(document.createXULElement("menuseparator"));
|
|
continue;
|
|
}
|
|
const menuitem = document.createXULElement("menuitem");
|
|
menuitem.setAttribute("data-l10n-id", item.l10nId);
|
|
if (item.type) {
|
|
menuitem.setAttribute("type", item.type);
|
|
}
|
|
if (item.checked) {
|
|
menuitem.setAttribute("checked", "true");
|
|
}
|
|
if (item.disabled) {
|
|
menuitem.setAttribute("disabled", "true");
|
|
}
|
|
menuitem.addEventListener(
|
|
"command",
|
|
() => {
|
|
try {
|
|
item.onClick?.();
|
|
} catch (ex) {
|
|
console.error(ex);
|
|
}
|
|
},
|
|
{ once: true }
|
|
);
|
|
popup.appendChild(menuitem);
|
|
}
|
|
|
|
popup.addEventListener(
|
|
"popuphidden",
|
|
() => {
|
|
popup.remove();
|
|
},
|
|
{ once: true }
|
|
);
|
|
|
|
popupSet.appendChild(popup);
|
|
|
|
if (opener.type === "anchor") {
|
|
popup.openPopup(opener.el, "after_end", 0, 0, true, false);
|
|
} else {
|
|
popup.openPopupAtScreen(opener.x, opener.y, true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True when the modifier configured for Glance activation is held.
|
|
*
|
|
* @param event
|
|
*/
|
|
_isGlanceActivation(event) {
|
|
if (!Services.prefs.getBoolPref("zen.glance.enabled", true)) {
|
|
return false;
|
|
}
|
|
const method = Services.prefs.getStringPref(
|
|
"zen.glance.activation-method",
|
|
"ctrl"
|
|
);
|
|
return (
|
|
(method === "ctrl" && event.ctrlKey) ||
|
|
(method === "alt" && event.altKey) ||
|
|
(method === "shift" && event.shiftKey) ||
|
|
(method === "meta" && event.metaKey)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Open `url` in a Glance overlay anchored to the clicked item.
|
|
*
|
|
* @param event
|
|
* @param url
|
|
*/
|
|
_openInGlance(event, url) {
|
|
const itemEl = event.currentTarget;
|
|
const rect = window.windowUtils.getBoundsWithoutFlushing(itemEl);
|
|
const tabPanelRect = window.windowUtils.getBoundsWithoutFlushing(
|
|
window.gBrowser.tabpanels
|
|
);
|
|
window.gZenGlanceManager.openGlance({
|
|
url,
|
|
clientX: rect.left - tabPanelRect.left,
|
|
clientY: rect.top - tabPanelRect.top,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
|
|
});
|
|
}
|
|
|
|
/** Close the parent ZenLibrary overlay. */
|
|
_closeLibrary() {
|
|
const host = this.getRootNode().host?.closest?.("zen-library");
|
|
// Section nodes live outside the ZenLibrary lit tree (they are returned
|
|
// from render()), so walk to the document and toggle via the static API.
|
|
if (host?.isOpen) {
|
|
host.constructor.toggle();
|
|
return;
|
|
}
|
|
const lib = document.querySelector("zen-library[open]");
|
|
if (lib) {
|
|
lib.constructor.toggle();
|
|
}
|
|
}
|
|
|
|
renderSearchResults() {
|
|
return html``;
|
|
}
|
|
|
|
render() {
|
|
const hasFilters = !!this._filters().length;
|
|
return html`
|
|
<link
|
|
rel="stylesheet"
|
|
href="chrome://browser/content/zen-styles/zen-library.css"
|
|
/>
|
|
<div class="search-bar">
|
|
<div class="search-urlbar">
|
|
<div class="search-urlbar-background"></div>
|
|
<div class="search-input-container">
|
|
<img
|
|
class="search-icon"
|
|
src="chrome://browser/skin/zen-icons/search-glass.svg"
|
|
alt=""
|
|
/>
|
|
<input
|
|
id="zen-library-search-input"
|
|
class="search-input"
|
|
type="text"
|
|
inputmode="search"
|
|
aria-autocomplete="list"
|
|
data-l10n-id=${this._searchPlaceholder}
|
|
@input=${this._onSearchInput}
|
|
.value=${this.inputValue}
|
|
/>
|
|
<button
|
|
class="search-clear-button"
|
|
tabindex="-1"
|
|
@click=${this._onClearSearch}
|
|
>
|
|
<img src="chrome://browser/skin/zen-icons/close.svg" alt="" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
${
|
|
hasFilters
|
|
? html`
|
|
<button
|
|
class="search-filter-button"
|
|
@click=${this._onFilterButtonClick}
|
|
>
|
|
<img
|
|
src="chrome://browser/skin/zen-icons/permissions.svg"
|
|
alt=""
|
|
/>
|
|
<label>${this._activeFilterLabel()}</label>
|
|
</button>
|
|
`
|
|
: ""
|
|
}
|
|
</div>
|
|
<div
|
|
class="search-results"
|
|
@scroll=${e => {
|
|
const el = e.currentTarget;
|
|
this.isScrolledToTop = el.scrollTop === 0;
|
|
this._onScroll?.(el);
|
|
}}
|
|
?scrolled-to-top=${this.isScrolledToTop}
|
|
>
|
|
${this.renderSearchResults()}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds progressive background rendering to SearchSection.
|
|
* Renders the first 50 items immediately, then fills the rest in idle chunks
|
|
* so the main thread stays responsive and the scrollbar tracks correctly.
|
|
*/
|
|
class ProgressiveSearchSection extends SearchSection {
|
|
static INITIAL_BATCH = 50;
|
|
static SCROLL_BATCH = 50;
|
|
static SCROLL_THRESHOLD_PX = 400;
|
|
|
|
_allItems = [];
|
|
|
|
#renderedItemCount = 0;
|
|
#fillScheduled = false;
|
|
|
|
_getRenderedSlice() {
|
|
return this._allItems.slice(0, this.#renderedItemCount);
|
|
}
|
|
|
|
/** Call when _allItems is fully replaced (new query / search term). */
|
|
_resetProgressiveRender() {
|
|
this.#renderedItemCount = Math.min(
|
|
ProgressiveSearchSection.INITIAL_BATCH,
|
|
this._allItems.length
|
|
);
|
|
this.isEmpty = this._allItems.length === 0;
|
|
this.#scheduleViewportFill();
|
|
}
|
|
|
|
/** Call when _allItems grows/shrinks but the rendered prefix should stick. */
|
|
_maintainProgressiveRender() {
|
|
this.#renderedItemCount = Math.min(
|
|
Math.max(
|
|
this.#renderedItemCount,
|
|
Math.min(ProgressiveSearchSection.INITIAL_BATCH, this._allItems.length)
|
|
),
|
|
this._allItems.length
|
|
);
|
|
this.isEmpty = this._allItems.length === 0;
|
|
this.#scheduleViewportFill();
|
|
}
|
|
|
|
/**
|
|
* Top up the rendered slice until the list is at least one viewport tall;
|
|
* a list shorter than the viewport never fires a scroll event, so without
|
|
* this the user could never trigger more loads.
|
|
*/
|
|
#scheduleViewportFill() {
|
|
if (this.#fillScheduled) {
|
|
return;
|
|
}
|
|
this.#fillScheduled = true;
|
|
this.updateComplete.then(() => {
|
|
this.#fillScheduled = false;
|
|
if (!this.isConnected) {
|
|
return;
|
|
}
|
|
const el = this.renderRoot.querySelector(".search-results");
|
|
if (!el) {
|
|
return;
|
|
}
|
|
if (
|
|
el.scrollHeight <= el.clientHeight &&
|
|
this.#renderedItemCount < this._allItems.length
|
|
) {
|
|
this.#growBy(ProgressiveSearchSection.SCROLL_BATCH);
|
|
this.#scheduleViewportFill();
|
|
}
|
|
});
|
|
}
|
|
|
|
#growBy(n) {
|
|
if (this.#renderedItemCount >= this._allItems.length) {
|
|
return;
|
|
}
|
|
this.#renderedItemCount = Math.min(
|
|
this.#renderedItemCount + n,
|
|
this._allItems.length
|
|
);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
/**
|
|
* Hook called from SearchSection's results scroll handler.
|
|
*
|
|
* @param el
|
|
*/
|
|
_onScroll(el) {
|
|
if (this.#renderedItemCount >= this._allItems.length) {
|
|
return;
|
|
}
|
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
if (distanceFromBottom <= ProgressiveSearchSection.SCROLL_THRESHOLD_PX) {
|
|
this.#growBy(ProgressiveSearchSection.SCROLL_BATCH);
|
|
}
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this._allItems = [];
|
|
super.disconnectedCallback();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* History section, backed by nsINavHistoryService
|
|
* with a live observer for real-time updates.
|
|
*/
|
|
class ZenLibraryHistorySection extends ProgressiveSearchSection {
|
|
static id = "history";
|
|
static label = "library-history-section-title";
|
|
|
|
/** @type {nsINavHistoryResult|null} */
|
|
#result = null;
|
|
|
|
/** @type {object|null} */
|
|
#observer = null;
|
|
|
|
/** Suppresses observer-triggered rebuilds during our own tree walk. */
|
|
#walking = false;
|
|
|
|
/** Coalesces rapid observer notifications into a single rebuild. */
|
|
#rebuildTimer = null;
|
|
|
|
get _searchPlaceholder() {
|
|
return "library-history-search-placeholder";
|
|
}
|
|
|
|
async connectedCallback() {
|
|
super.connectedCallback();
|
|
try {
|
|
await this.#init();
|
|
} catch (ex) {
|
|
console.error("[ZenLibrary/History] Failed to initialize:", ex);
|
|
}
|
|
}
|
|
|
|
async #init() {
|
|
this.#executeQuery("");
|
|
await this.updateComplete;
|
|
// Element may have been removed while awaiting; #teardownResult already ran.
|
|
if (!this.isConnected) {
|
|
return;
|
|
}
|
|
this.requestUpdate();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
// Teardown before super: a pending #rebuildTimer could otherwise fire
|
|
// #walkAndPopulate against base-class state super is about to clear.
|
|
this.#teardownResult();
|
|
super.disconnectedCallback();
|
|
}
|
|
|
|
_onSearch(term) {
|
|
this.#executeQuery(term);
|
|
}
|
|
|
|
/** @param {string} searchTerm - empty string for the default chronological view. */
|
|
#executeQuery(searchTerm) {
|
|
this.#teardownResult();
|
|
|
|
const NHQO = Ci.nsINavHistoryQueryOptions;
|
|
// eslint-disable-next-line no-shadow
|
|
const history = lazy.PlacesUtils.history;
|
|
const query = history.getNewQuery();
|
|
const options = history.getNewQueryOptions();
|
|
|
|
options.sortingMode = NHQO.SORT_BY_DATE_DESCENDING;
|
|
|
|
if (searchTerm) {
|
|
query.searchTerms = searchTerm;
|
|
options.includeHidden = true;
|
|
options.maxResults = 500;
|
|
options.resultType = NHQO.RESULTS_AS_URI; // Required by Places when searchTerms is set.
|
|
} else {
|
|
options.maxResults = 2500;
|
|
options.resultType = NHQO.RESULTS_AS_VISIT; // De-duplicated per day in #walkAndPopulate.
|
|
}
|
|
|
|
const result = history.executeQuery(query, options);
|
|
this.#observer = this.#createObserver();
|
|
result.addObserver(this.#observer);
|
|
this.#result = result;
|
|
|
|
result.root.containerOpen = true; // Triggers the native query and populates children.
|
|
|
|
this.#walkAndPopulate();
|
|
this._resetProgressiveRender();
|
|
this.requestUpdate();
|
|
}
|
|
|
|
#walkAndPopulate() {
|
|
this.#walking = true;
|
|
try {
|
|
this._allItems = [];
|
|
|
|
const root = this.#result?.root;
|
|
if (!root?.containerOpen) {
|
|
return;
|
|
}
|
|
|
|
const cutoff = this.#filterCutoff();
|
|
const yesterdayWindow = this.#yesterdayWindow();
|
|
|
|
// RESULTS_AS_VISIT produces one node per visit; de-duplicate per day-bucket.
|
|
const seenNodes = new Set();
|
|
for (let i = 0; i < root.childCount; i++) {
|
|
const node = root.getChild(i);
|
|
const date = new Date(node.time / 1000);
|
|
const ts = date.getTime();
|
|
|
|
if (yesterdayWindow) {
|
|
if (ts < yesterdayWindow.start || ts >= yesterdayWindow.end) {
|
|
continue;
|
|
}
|
|
} else if (cutoff !== null && ts < cutoff) {
|
|
continue;
|
|
}
|
|
|
|
const dayStart = new Date(date).setHours(0, 0, 0, 0);
|
|
const key = `${dayStart}|${node.uri}`;
|
|
if (seenNodes.has(key)) {
|
|
continue;
|
|
}
|
|
seenNodes.add(key);
|
|
|
|
this._allItems.push({
|
|
title: node.title,
|
|
url: node.uri,
|
|
date,
|
|
});
|
|
}
|
|
|
|
this.log(
|
|
`#walkAndPopulate — ${this._allItems.length} items, search=${!!this.searchTerm}, filter=${this.activeFilter}`
|
|
);
|
|
} finally {
|
|
this.#walking = false;
|
|
}
|
|
}
|
|
|
|
/** Lower bound (timestamp ms) for the active time-range filter, or null for "all". */
|
|
#filterCutoff() {
|
|
const filter = HISTORY_FILTERS.find(f => f.id === this.activeFilter);
|
|
if (!filter || filter.days === null) {
|
|
return null;
|
|
}
|
|
if (filter.id === "today") {
|
|
return new Date().setHours(0, 0, 0, 0);
|
|
}
|
|
if (filter.id === "yesterday") {
|
|
return null; // handled by #yesterdayWindow
|
|
}
|
|
return new Date().setHours(0, 0, 0, 0) - filter.days * DAY_MS;
|
|
}
|
|
|
|
/** Yesterday is a single-day window, not just a lower bound. */
|
|
#yesterdayWindow() {
|
|
if (this.activeFilter !== "yesterday") {
|
|
return null;
|
|
}
|
|
const start = new Date().setHours(0, 0, 0, 0) - DAY_MS;
|
|
return { start, end: start + DAY_MS };
|
|
}
|
|
|
|
_filters() {
|
|
return HISTORY_FILTERS;
|
|
}
|
|
|
|
_onFilterChange(_id) {
|
|
this.#walkAndPopulate();
|
|
this._resetProgressiveRender();
|
|
this.requestUpdate();
|
|
}
|
|
|
|
#createObserver() {
|
|
const rebuild = () => this.#scheduleRebuild();
|
|
return {
|
|
QueryInterface: ChromeUtils.generateQI([
|
|
"nsINavHistoryResultObserver",
|
|
"nsISupportsWeakReference",
|
|
]),
|
|
skipHistoryDetailsNotifications: true,
|
|
nodeInserted: rebuild,
|
|
nodeRemoved: rebuild,
|
|
nodeTitleChanged: rebuild,
|
|
invalidateContainer: rebuild,
|
|
containerStateChanged() {},
|
|
nodeHistoryDetailsChanged() {},
|
|
nodeTagsChanged() {},
|
|
nodeDateAddedChanged() {},
|
|
nodeLastModifiedChanged() {},
|
|
nodeKeywordChanged() {},
|
|
nodeURIChanged() {},
|
|
nodeIconChanged() {},
|
|
sortingChanged() {},
|
|
nodeMoved() {},
|
|
batching() {},
|
|
};
|
|
}
|
|
|
|
#scheduleRebuild() {
|
|
if (this.#walking) {
|
|
return;
|
|
}
|
|
clearTimeout(this.#rebuildTimer);
|
|
this.#rebuildTimer = setTimeout(() => {
|
|
this.#rebuildTimer = null;
|
|
if (!this.#result) {
|
|
return;
|
|
}
|
|
this.#walkAndPopulate();
|
|
this._maintainProgressiveRender();
|
|
this.requestUpdate();
|
|
}, 250);
|
|
}
|
|
|
|
#teardownResult() {
|
|
clearTimeout(this.#rebuildTimer);
|
|
this.#rebuildTimer = null;
|
|
|
|
if (this.#result) {
|
|
if (this.#observer) {
|
|
this.#result.removeObserver(this.#observer);
|
|
this.#observer = null;
|
|
}
|
|
try {
|
|
this.#result.root.containerOpen = false;
|
|
} catch {
|
|
/* Container may already be closed. */
|
|
}
|
|
this.#result = null;
|
|
}
|
|
|
|
this._allItems = [];
|
|
}
|
|
|
|
renderSearchResults() {
|
|
if (!this.#result) {
|
|
return html``;
|
|
}
|
|
if (this.isEmpty) {
|
|
return html`
|
|
<div
|
|
class="empty-state"
|
|
data-l10n-id=${this.searchTerm ? "library-search-no-results" : "library-history-empty"}
|
|
></div>
|
|
`;
|
|
}
|
|
|
|
const slice = this._getRenderedSlice();
|
|
this.log(
|
|
`renderSearchResults — ${slice.length}/${this._allItems.length} items`
|
|
);
|
|
|
|
const rows = [];
|
|
let lastLabel = null;
|
|
|
|
for (const v of slice) {
|
|
const dayLabel = this._dayLabel(v.date);
|
|
if (dayLabel !== lastLabel) {
|
|
lastLabel = dayLabel;
|
|
rows.push(html`
|
|
<label class="library-date-separator">${lastLabel}</label>
|
|
`);
|
|
}
|
|
const domain =
|
|
v.url?.replace(/^[\w-]+:\/\//, "").replace(/\/$/, "") || "";
|
|
const time = this._formatTime(v.date);
|
|
const sublabel = [domain, time].filter(Boolean).join(" · ");
|
|
rows.push(
|
|
this._renderItem({
|
|
key: `${v.date.getTime()}|${v.url}`,
|
|
iconSrc: `page-icon:${v.url || ""}`,
|
|
label: v.title || v.url,
|
|
sublabel,
|
|
payload: v,
|
|
})
|
|
);
|
|
}
|
|
|
|
return html`${rows}`;
|
|
}
|
|
|
|
/**
|
|
* Plain click on a history row opens the page in a Glance overlay; holding
|
|
* Ctrl (or any other modifier / middle-click) falls back to the standard
|
|
* "where to open" routing.
|
|
*
|
|
* @param event
|
|
* @param item
|
|
*/
|
|
_onItemClick(event, item) {
|
|
if (event.button === 2) {
|
|
return;
|
|
}
|
|
if (!item?.url) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
|
|
const hasModifier =
|
|
event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
|
|
const isMiddleClick = event.button === 1;
|
|
|
|
if (
|
|
!hasModifier &&
|
|
!isMiddleClick &&
|
|
Services.prefs.getBoolPref("zen.glance.enabled", true)
|
|
) {
|
|
this._openInGlance(event, item.url);
|
|
this._closeLibrary();
|
|
return;
|
|
}
|
|
|
|
const where = lazy.BrowserUtils.whereToOpenLink(event, false, true);
|
|
window.openTrustedLinkIn(item.url, where === "current" ? "tab" : where);
|
|
this._closeLibrary();
|
|
}
|
|
|
|
_renderItemActions(item) {
|
|
if (!item?.url) {
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
icon: "chrome://browser/skin/zen-icons/edit-delete.svg",
|
|
l10nId: "library-history-action-remove",
|
|
onClick: () => {
|
|
lazy.PlacesUtils.history.remove(item.url).catch(console.error);
|
|
},
|
|
},
|
|
{
|
|
icon: "chrome://browser/skin/zen-icons/arrow-right.svg",
|
|
l10nId: "library-history-action-open-tab",
|
|
onClick: () => {
|
|
window.openTrustedLinkIn(item.url, "tab");
|
|
this._closeLibrary();
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
_buildContextMenu(item) {
|
|
if (!item?.url) {
|
|
return null;
|
|
}
|
|
return [
|
|
{
|
|
l10nId: "library-item-context-open",
|
|
onClick: () => {
|
|
window.openTrustedLinkIn(item.url, "tab");
|
|
this._closeLibrary();
|
|
},
|
|
},
|
|
{
|
|
l10nId: "library-item-context-open-glance",
|
|
onClick: () => {
|
|
// Use the section element as the anchor — context menu opens at
|
|
// the cursor and the original item element isn't tracked here.
|
|
const fakeEvent = { currentTarget: this };
|
|
this._openInGlance(fakeEvent, item.url);
|
|
this._closeLibrary();
|
|
},
|
|
},
|
|
{
|
|
l10nId: "library-item-context-open-new-window",
|
|
onClick: () => {
|
|
window.openTrustedLinkIn(item.url, "window");
|
|
this._closeLibrary();
|
|
},
|
|
},
|
|
{ separator: true },
|
|
{
|
|
l10nId: "library-item-context-copy-url",
|
|
onClick: () => lazy.clipboardHelper.copyString(item.url),
|
|
},
|
|
{
|
|
l10nId: "library-item-context-delete-history",
|
|
onClick: () => {
|
|
lazy.PlacesUtils.history.remove(item.url).catch(console.error);
|
|
},
|
|
},
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Downloads section: date-grouped, progressively-rendered download list.
|
|
* Single additions use binary insertion; batch loads sort once on completion.
|
|
*/
|
|
class ZenLibraryDownloadsSection extends ProgressiveSearchSection {
|
|
static id = "downloads";
|
|
static label = "library-downloads-section-title";
|
|
|
|
#downloadsData = null;
|
|
#allDownloads = [];
|
|
#downloadsView = null;
|
|
#batchLoading = false;
|
|
|
|
get _searchPlaceholder() {
|
|
return "library-downloads-search-placeholder";
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
try {
|
|
this.#init();
|
|
} catch (ex) {
|
|
console.error("[ZenLibrary/Downloads] Failed to initialize:", ex);
|
|
}
|
|
}
|
|
|
|
#init() {
|
|
this.#downloadsData = lazy.DownloadsCommon.getData(window, true);
|
|
this.#setupDownloadsView();
|
|
this.#downloadsData.addView(this.#downloadsView);
|
|
}
|
|
|
|
#setupDownloadsView() {
|
|
this.#downloadsView = {
|
|
onDownloadAdded: dl => {
|
|
if (this.#batchLoading) {
|
|
this.#allDownloads.push(dl);
|
|
return;
|
|
}
|
|
this.#insertSorted(dl);
|
|
this.#rebuildItems();
|
|
this._maintainProgressiveRender();
|
|
this.requestUpdate();
|
|
},
|
|
onDownloadBatchStarting: () => {
|
|
this.#batchLoading = true;
|
|
},
|
|
onDownloadBatchEnded: async () => {
|
|
this.#batchLoading = false;
|
|
this.#allDownloads.sort(
|
|
(a, b) => this.#downloadTime(b) - this.#downloadTime(a)
|
|
);
|
|
this.#rebuildItems();
|
|
this.log(
|
|
`batch complete — ${this.#allDownloads.length} downloads loaded`
|
|
);
|
|
await this.updateComplete;
|
|
// Guard: library may have closed while the batch was loading.
|
|
if (!this.isConnected) {
|
|
return;
|
|
}
|
|
this._resetProgressiveRender();
|
|
this.requestUpdate();
|
|
},
|
|
onDownloadChanged: () => {
|
|
this.requestUpdate();
|
|
},
|
|
onDownloadRemoved: dl => {
|
|
const idx = this.#allDownloads.indexOf(dl);
|
|
if (idx !== -1) {
|
|
this.#allDownloads.splice(idx, 1);
|
|
this.#rebuildItems();
|
|
this._maintainProgressiveRender();
|
|
this.requestUpdate();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
// Teardown before super so base-class cleanup doesn't observe nullified state.
|
|
try {
|
|
this.#downloadsData?.removeView(this.#downloadsView);
|
|
} catch (ex) {
|
|
console.error("[ZenLibrary/Downloads] Failed to teardown:", ex);
|
|
}
|
|
this.#downloadsView = null;
|
|
this.#downloadsData = null;
|
|
this.#allDownloads = [];
|
|
super.disconnectedCallback();
|
|
}
|
|
|
|
_onSearch(term) {
|
|
this.log(
|
|
`_onSearch — term: "${term}", total: ${this.#allDownloads.length}`
|
|
);
|
|
this.#rebuildItems();
|
|
this._resetProgressiveRender();
|
|
this.requestUpdate();
|
|
}
|
|
|
|
_filters() {
|
|
return DOWNLOAD_FILTERS;
|
|
}
|
|
|
|
_onFilterChange(_id) {
|
|
this.#rebuildItems();
|
|
this._resetProgressiveRender();
|
|
this.requestUpdate();
|
|
}
|
|
|
|
renderSearchResults() {
|
|
const slice = this._getRenderedSlice();
|
|
|
|
if (this.isEmpty) {
|
|
return html`
|
|
<div
|
|
class="empty-state"
|
|
data-l10n-id=${this.searchTerm ? "library-search-no-results" : "library-downloads-empty"}
|
|
></div>
|
|
`;
|
|
}
|
|
|
|
const groups = this.#groupByDate(slice);
|
|
return html`
|
|
${Array.from(groups).map(
|
|
([key, downloads]) => html`
|
|
<label class="library-date-separator">
|
|
${this._dayLabel(new Date(key))}
|
|
</label>
|
|
${downloads.map(dl => this.#renderDownloadItem(dl))}
|
|
`
|
|
)}
|
|
`;
|
|
}
|
|
|
|
#renderDownloadItem(dl) {
|
|
// getDisplayName may return an {l10n} object for blocked/spam downloads.
|
|
const displayName = lazy.DownloadsViewUI.getDisplayName(dl);
|
|
const nameStr =
|
|
typeof displayName === "string" ? displayName : dl.source.url;
|
|
const iconPath = dl.target.path
|
|
? `moz-icon://${dl.target.path}?size=16`
|
|
: "moz-icon://.unknown?size=16";
|
|
|
|
const sublabelParts = [
|
|
this.#getStatusLabel(dl),
|
|
this.#sourceDomain(dl),
|
|
this._formatTime(this.#downloadTime(dl)),
|
|
].filter(Boolean);
|
|
|
|
return this._renderItem({
|
|
key: `${this.#downloadTime(dl)}|${dl.source.url}`,
|
|
iconSrc: iconPath,
|
|
label: nameStr,
|
|
sublabel: sublabelParts.join(" · "),
|
|
payload: dl,
|
|
});
|
|
}
|
|
|
|
#sourceDomain(dl) {
|
|
const url = dl.source.originalUrl || dl.source.url || "";
|
|
try {
|
|
return new URL(url).hostname.replace(/^www\./, "");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
_onItemClick(event, dl) {
|
|
if (event.button === 2) {
|
|
return;
|
|
}
|
|
if (!dl) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
|
|
const state = lazy.DownloadsCommon.stateOfDownload(dl);
|
|
const isFinished = state === lazy.DownloadsCommon.DOWNLOAD_FINISHED;
|
|
|
|
// Glance modifier — preview the source URL, regardless of file state.
|
|
if (event.button !== 1 && this._isGlanceActivation(event)) {
|
|
const previewUrl =
|
|
dl.source.referrerInfo?.originalReferrer?.spec || dl.source.url;
|
|
if (previewUrl) {
|
|
this._openInGlance(event, previewUrl);
|
|
this._closeLibrary();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Middle-click or finished file with no path: fall back to opening source URL.
|
|
if (event.button === 1 || !isFinished || !dl.target.path) {
|
|
const url = dl.source.originalUrl || dl.source.url;
|
|
if (url) {
|
|
const where = lazy.BrowserUtils.whereToOpenLink(event, false, true);
|
|
window.openTrustedLinkIn(url, where === "current" ? "tab" : where);
|
|
this._closeLibrary();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Default: launch the downloaded file with the system handler.
|
|
if (dl.target.exists !== false && !dl.deleted) {
|
|
lazy.DownloadsCommon.openDownload(dl).catch(console.error);
|
|
}
|
|
}
|
|
|
|
_buildContextMenu(dl) {
|
|
if (!dl) {
|
|
return null;
|
|
}
|
|
const state = lazy.DownloadsCommon.stateOfDownload(dl);
|
|
const isFinished = state === lazy.DownloadsCommon.DOWNLOAD_FINISHED;
|
|
const fileExists = isFinished && dl.target.exists !== false && !dl.deleted;
|
|
const sourceUrl = dl.source.originalUrl || dl.source.url;
|
|
|
|
const items = [];
|
|
if (fileExists) {
|
|
items.push({
|
|
l10nId: "library-item-context-open",
|
|
onClick: () => {
|
|
lazy.DownloadsCommon.openDownload(dl).catch(console.error);
|
|
},
|
|
});
|
|
items.push({
|
|
l10nId: "library-item-context-show-in-folder",
|
|
onClick: () => {
|
|
try {
|
|
const file = new lazy.FileUtils.File(dl.target.path);
|
|
lazy.DownloadsCommon.showDownloadedFile(file);
|
|
} catch (ex) {
|
|
console.error(ex);
|
|
}
|
|
},
|
|
});
|
|
items.push({ separator: true });
|
|
}
|
|
if (sourceUrl) {
|
|
items.push({
|
|
l10nId: "library-item-context-open-source",
|
|
onClick: () => {
|
|
window.openTrustedLinkIn(sourceUrl, "tab");
|
|
this._closeLibrary();
|
|
},
|
|
});
|
|
items.push({
|
|
l10nId: "library-item-context-copy-url",
|
|
onClick: () => lazy.clipboardHelper.copyString(sourceUrl),
|
|
});
|
|
items.push({ separator: true });
|
|
}
|
|
items.push({
|
|
l10nId: "library-item-context-remove",
|
|
onClick: () => {
|
|
lazy.DownloadsCommon.deleteDownload(dl).catch(console.error);
|
|
},
|
|
});
|
|
return items;
|
|
}
|
|
|
|
#downloadTime(dl) {
|
|
// Fallback to 0 (epoch) keeps binary search comparisons valid.
|
|
return (dl.endTime ?? dl.startTime) || 0;
|
|
}
|
|
|
|
/**
|
|
* Binary insertion into #allDownloads (newest-first).
|
|
* O(log N) vs O(N log N) for a full sort on every add.
|
|
*
|
|
* @param {object} dl - Download object from DownloadsCommon.getData()
|
|
*/
|
|
#insertSorted(dl) {
|
|
const time = this.#downloadTime(dl);
|
|
let lo = 0;
|
|
let hi = this.#allDownloads.length;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >>> 1;
|
|
if (this.#downloadTime(this.#allDownloads[mid]) >= time) {
|
|
lo = mid + 1;
|
|
} else {
|
|
hi = mid;
|
|
}
|
|
}
|
|
this.#allDownloads.splice(lo, 0, dl);
|
|
}
|
|
|
|
#rebuildItems() {
|
|
const term = this.searchTerm?.trim().toLowerCase();
|
|
const filter = this.activeFilter;
|
|
this._allItems = this.#allDownloads.filter(dl => {
|
|
if (term && !this.#matchesSearchTerm(dl, term)) {
|
|
return false;
|
|
}
|
|
return this.#matchesStatusFilter(dl, filter);
|
|
});
|
|
}
|
|
|
|
#matchesStatusFilter(dl, filter) {
|
|
if (!filter || filter === "all") {
|
|
return true;
|
|
}
|
|
const state = lazy.DownloadsCommon.stateOfDownload(dl);
|
|
const C = lazy.DownloadsCommon;
|
|
switch (filter) {
|
|
case "completed":
|
|
return state === C.DOWNLOAD_FINISHED;
|
|
case "in-progress":
|
|
return (
|
|
state === C.DOWNLOAD_DOWNLOADING || state === C.DOWNLOAD_NOTSTARTED
|
|
);
|
|
case "failed":
|
|
return (
|
|
state === C.DOWNLOAD_FAILED ||
|
|
state === C.DOWNLOAD_CANCELED ||
|
|
state === C.DOWNLOAD_DIRTY ||
|
|
state === C.DOWNLOAD_BLOCKED_PARENTAL ||
|
|
state === C.DOWNLOAD_BLOCKED_CONTENT_ANALYSIS
|
|
);
|
|
case "paused":
|
|
return state === C.DOWNLOAD_PAUSED;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
#matchesSearchTerm(dl, term) {
|
|
if (!term) {
|
|
return true;
|
|
}
|
|
const displayName = lazy.DownloadsViewUI.getDisplayName(dl);
|
|
const nameStr =
|
|
typeof displayName === "string" ? displayName.toLowerCase() : "";
|
|
const url = (dl.source.originalUrl || dl.source.url || "").toLowerCase();
|
|
return nameStr.includes(term) || url.includes(term);
|
|
}
|
|
|
|
#groupByDate(downloads) {
|
|
const groups = new Map();
|
|
for (const dl of downloads) {
|
|
const t = this.#downloadTime(dl);
|
|
if (!t) {
|
|
continue;
|
|
}
|
|
const key = new Date(t).setHours(0, 0, 0, 0);
|
|
if (!groups.has(key)) {
|
|
groups.set(key, []);
|
|
}
|
|
groups.get(key).push(dl);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
#getStatusLabel(dl) {
|
|
const strings = lazy.DownloadsCommon.strings;
|
|
const state = lazy.DownloadsCommon.stateOfDownload(dl);
|
|
|
|
switch (state) {
|
|
case lazy.DownloadsCommon.DOWNLOAD_DOWNLOADING: {
|
|
const totalBytes = dl.hasProgress ? dl.totalBytes : -1;
|
|
// eslint-disable-next-line no-shadow
|
|
const [status] = lazy.DownloadUtils.getDownloadStatus(
|
|
dl.currentBytes,
|
|
totalBytes,
|
|
dl.speed
|
|
);
|
|
return status;
|
|
}
|
|
case lazy.DownloadsCommon.DOWNLOAD_FINISHED:
|
|
if (dl.deleted) {
|
|
return strings.fileDeleted;
|
|
}
|
|
if (dl.target.exists === false) {
|
|
return strings.fileMovedOrMissing;
|
|
}
|
|
return lazy.DownloadsViewUI.getSizeWithUnits(dl) || strings.sizeUnknown;
|
|
case lazy.DownloadsCommon.DOWNLOAD_FAILED:
|
|
return strings.stateFailed;
|
|
case lazy.DownloadsCommon.DOWNLOAD_CANCELED:
|
|
return strings.stateCanceled;
|
|
case lazy.DownloadsCommon.DOWNLOAD_PAUSED: {
|
|
const total = dl.hasProgress ? dl.totalBytes : -1;
|
|
const transfer = lazy.DownloadUtils.getTransferTotal(
|
|
dl.currentBytes,
|
|
total
|
|
);
|
|
return strings.statusSeparatorBeforeNumber(
|
|
strings.statePaused,
|
|
transfer
|
|
);
|
|
}
|
|
case lazy.DownloadsCommon.DOWNLOAD_BLOCKED_PARENTAL:
|
|
return strings.stateBlockedParentalControls;
|
|
case lazy.DownloadsCommon.DOWNLOAD_DIRTY:
|
|
case lazy.DownloadsCommon.DOWNLOAD_BLOCKED_CONTENT_ANALYSIS:
|
|
return strings.stateDirty ?? strings.stateFailed;
|
|
case lazy.DownloadsCommon.DOWNLOAD_NOTSTARTED:
|
|
return strings.stateStarting;
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Spaces section: Borgir man will do it :) */
|
|
class ZenLibrarySpacesSection extends LibrarySection {
|
|
static largeContent = true;
|
|
static id = "spaces";
|
|
static label = "library-spaces-section-title";
|
|
}
|
|
|
|
export const ZenLibrarySections = {
|
|
history: ZenLibraryHistorySection,
|
|
downloads: ZenLibraryDownloadsSection,
|
|
spaces: ZenLibrarySpacesSection,
|
|
};
|
|
|
|
for (const Section of Object.values(ZenLibrarySections)) {
|
|
customElements.define(`zen-library-section-${Section.id}`, Section);
|
|
}
|