mirror of
https://github.com/zen-browser/desktop.git
synced 2026-04-03 06:09:19 +00:00
UI, search (clicking item does nothing)
Signed-off-by: JustAdumbPrsn <73780892+JustAdumbPrsn@users.noreply.github.com>
This commit is contained in:
@@ -1,23 +1,46 @@
|
||||
// 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/.
|
||||
/*
|
||||
* 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";
|
||||
|
||||
let lazy = {};
|
||||
const lazy = {};
|
||||
|
||||
ChromeUtils.defineESModuleGetters(lazy, {
|
||||
Downloads: "resource://gre/modules/Downloads.sys.mjs",
|
||||
DownloadHistory: "resource://gre/modules/DownloadHistory.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",
|
||||
PlacesQuery: "resource://gre/modules/PlacesQuery.sys.mjs",
|
||||
PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
|
||||
});
|
||||
|
||||
ChromeUtils.defineLazyGetter(lazy, "l10n", function () {
|
||||
return new Localization(["browser/zen-library.ftl"], true);
|
||||
ChromeUtils.defineLazyGetter(
|
||||
lazy,
|
||||
"l10n",
|
||||
() => new Localization(["browser/zen-library.ftl"], true),
|
||||
);
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -28,23 +51,107 @@ class LibrarySection extends MozLitElement {
|
||||
static get label() {
|
||||
throw new Error("LibrarySection subclass must define a static `label`.");
|
||||
}
|
||||
|
||||
_l10n(key, fallback) {
|
||||
return lazy.l10n.formatValueSync(key) || fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
};
|
||||
|
||||
#canDebug = false;
|
||||
#searchTimer = null;
|
||||
|
||||
connectedCallback() {
|
||||
this.searchTerm = "";
|
||||
// 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;
|
||||
}
|
||||
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.searchTerm = event.target.value;
|
||||
this.requestUpdate();
|
||||
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", "Today");
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return this._l10n("library-history-yesterday", "Yesterday");
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return WEEKDAY_FORMATTER.format(d);
|
||||
}
|
||||
return LONG_DATE_FORMATTER.format(d);
|
||||
}
|
||||
|
||||
get _searchPlaceholder() {
|
||||
return this._l10n("library-search-placeholder", "Search…");
|
||||
}
|
||||
|
||||
_formatTime(date) {
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return TIME_FORMATTER.format(d);
|
||||
}
|
||||
|
||||
renderSearchResults() {
|
||||
@@ -57,134 +164,637 @@ class SearchSection extends LibrarySection {
|
||||
rel="stylesheet"
|
||||
href="chrome://browser/content/zen-styles/zen-library.css"
|
||||
/>
|
||||
<hbox class="search-section">
|
||||
<image src="chrome://browser/skin/zen-icons/search-glass.svg" />
|
||||
<input
|
||||
class="search-input"
|
||||
type="search"
|
||||
placeholder=${lazy.l10n.formatValueSync("library-search-placeholder")}
|
||||
@input=${this._onSearchInput}
|
||||
.value=${this.searchTerm}
|
||||
/>
|
||||
</hbox>
|
||||
<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"
|
||||
placeholder=${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>
|
||||
<button class="search-filter-button">
|
||||
<img src="chrome://browser/skin/zen-icons/search-glass.svg" alt="" />
|
||||
<span>${this._l10n("library-filter-button", "Filter")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-results">${this.renderSearchResults()}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// History section
|
||||
class ZenLibraryHistorySection extends SearchSection {
|
||||
/**
|
||||
* 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 {
|
||||
_allItems = [];
|
||||
|
||||
#renderedItemCount = 0;
|
||||
#renderTask = null;
|
||||
|
||||
_getRenderedSlice() {
|
||||
return this._allItems.slice(0, this.#renderedItemCount);
|
||||
}
|
||||
|
||||
/** Call when _allItems is fully replaced (new query / search term). */
|
||||
_resetProgressiveRender() {
|
||||
this.#cancelTask();
|
||||
this.#renderedItemCount = Math.min(50, this._allItems.length);
|
||||
this.isEmpty = this._allItems.length === 0;
|
||||
this.#scheduleNextBatch();
|
||||
}
|
||||
|
||||
/** Call when _allItems grows incrementally to avoid resetting scroll position. */
|
||||
_maintainProgressiveRender() {
|
||||
this.#renderedItemCount = Math.max(
|
||||
this.#renderedItemCount,
|
||||
Math.min(50, this._allItems.length),
|
||||
);
|
||||
this.isEmpty = this._allItems.length === 0;
|
||||
this.#scheduleNextBatch();
|
||||
}
|
||||
|
||||
#scheduleNextBatch() {
|
||||
if (this.#renderedItemCount >= this._allItems.length) {
|
||||
return;
|
||||
}
|
||||
this.#renderTask = requestIdleCallback(() => {
|
||||
this.#renderTask = null;
|
||||
this.#renderedItemCount = Math.min(
|
||||
this.#renderedItemCount + 100,
|
||||
this._allItems.length,
|
||||
);
|
||||
this.requestUpdate();
|
||||
this.#scheduleNextBatch();
|
||||
});
|
||||
}
|
||||
|
||||
#cancelTask() {
|
||||
if (this.#renderTask) {
|
||||
cancelIdleCallback(this.#renderTask);
|
||||
this.#renderTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// Cancel before super so no stale idle callback fires against cleared base state.
|
||||
this.#cancelTask();
|
||||
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";
|
||||
|
||||
#placesQuery = null;
|
||||
#history = null;
|
||||
/** @type {nsINavHistoryResult|null} */
|
||||
#result = null;
|
||||
|
||||
renderSearchResults() {
|
||||
if (this.isEmpty) {
|
||||
return html`<div class="empty-state">
|
||||
${lazy.l10n.formatValueSync("library-history-empty")}
|
||||
</div>`;
|
||||
}
|
||||
return html`${this.searchTerm}`;
|
||||
/** @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 this._l10n("library-history-search-placeholder", "Search History…");
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
try {
|
||||
this.#placesQuery = new lazy.PlacesQuery();
|
||||
this.#history = await this.#placesQuery.getHistory();
|
||||
this.isEmpty = this.#history.size === 0;
|
||||
this.#placesQuery.observeHistory((newHistory) => {
|
||||
this.#history = newHistory;
|
||||
this.isEmpty = this.#history.size === 0;
|
||||
this.requestUpdate();
|
||||
});
|
||||
this.requestUpdate();
|
||||
await this.#init();
|
||||
} catch (ex) {
|
||||
console.error("Zen Library: Failed to initialize history section.", 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();
|
||||
this.#placesQuery?.close();
|
||||
this.#placesQuery = null;
|
||||
this.#history = null;
|
||||
}
|
||||
|
||||
_onSearch(term) {
|
||||
this.#executeQuery(term);
|
||||
}
|
||||
|
||||
/** @param {string} searchTerm - empty string for the default chronological view. */
|
||||
#executeQuery(searchTerm) {
|
||||
this.#teardownResult();
|
||||
|
||||
const NHQO = Ci.nsINavHistoryQueryOptions;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 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}`,
|
||||
);
|
||||
} finally {
|
||||
this.#walking = false;
|
||||
}
|
||||
}
|
||||
|
||||
#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">
|
||||
${this.searchTerm
|
||||
? this._l10n("library-search-no-results", "No results")
|
||||
: this._l10n("library-history-empty", "No history found")}
|
||||
</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`
|
||||
<div class="library-date-separator">
|
||||
<span>${lastLabel}</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
rows.push(html`
|
||||
<div class="library-item">
|
||||
<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="page-icon:${v.url || ""}"
|
||||
alt=""
|
||||
role="presentation"
|
||||
/>
|
||||
</div>
|
||||
<div class="library-item-label-container">
|
||||
<span class="library-item-label">${v.title || v.url}</span>
|
||||
<span class="library-item-sublabel">${v.url}</span>
|
||||
</div>
|
||||
<span class="library-item-time">${this._formatTime(v.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return html`${rows}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Downloads section
|
||||
class ZenLibraryDownloadsSection extends SearchSection {
|
||||
/**
|
||||
* 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";
|
||||
|
||||
#downloadList = null;
|
||||
#downloads = [];
|
||||
#downloadsData = null;
|
||||
#allDownloads = [];
|
||||
#downloadsView = null;
|
||||
#batchLoading = false;
|
||||
|
||||
renderSearchResults() {
|
||||
if (this.isEmpty) {
|
||||
return html`<div class="empty-state">
|
||||
${lazy.l10n.formatValueSync("library-downloads-empty")}
|
||||
</div>`;
|
||||
}
|
||||
return html`${this.searchTerm}`;
|
||||
get _searchPlaceholder() {
|
||||
return this._l10n(
|
||||
"library-downloads-search-placeholder",
|
||||
"Search Downloads…",
|
||||
);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
try {
|
||||
this.#downloadList = await lazy.DownloadHistory.getList({
|
||||
type: lazy.Downloads.PUBLIC,
|
||||
});
|
||||
this.#downloadsView = {
|
||||
onDownloadAdded: (dl) => {
|
||||
// During the initial batch replay addView fires onDownloadAdded for
|
||||
// every existing download oldest-first, so we push to preserve order.
|
||||
// After init, new downloads are unshifted to the front.
|
||||
if (this.#batchLoading) {
|
||||
this.#downloads.push(dl);
|
||||
} else {
|
||||
this.#downloads.unshift(dl);
|
||||
this.isEmpty = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
},
|
||||
onDownloadBatchEnded: () => {
|
||||
this.#batchLoading = false;
|
||||
this.isEmpty = this.#downloads.length === 0;
|
||||
this.requestUpdate();
|
||||
},
|
||||
onDownloadChanged: (dl) => {
|
||||
this.requestUpdate();
|
||||
},
|
||||
onDownloadRemoved: (dl) => {
|
||||
this.#downloads = this.#downloads.filter((d) => d !== dl);
|
||||
this.isEmpty = this.#downloads.length === 0;
|
||||
this.requestUpdate();
|
||||
},
|
||||
};
|
||||
this.#batchLoading = true;
|
||||
this.#downloadList.addView(this.#downloadsView);
|
||||
this.#init();
|
||||
} catch (ex) {
|
||||
console.error("Zen Library: Failed to initialize downloads section.", 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() {
|
||||
super.disconnectedCallback();
|
||||
if (this.#downloadList && this.#downloadsView) {
|
||||
this.#downloadList.removeView(this.#downloadsView);
|
||||
// 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.#downloadList = null;
|
||||
this.#downloadsView = null;
|
||||
this.#downloads = [];
|
||||
this.#downloadsData = null;
|
||||
this.#allDownloads = [];
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
_onSearch(term) {
|
||||
this.log(
|
||||
`_onSearch — term: "${term}", total: ${this.#allDownloads.length}`,
|
||||
);
|
||||
this.#rebuildItems();
|
||||
this._resetProgressiveRender();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
renderSearchResults() {
|
||||
const slice = this._getRenderedSlice();
|
||||
|
||||
if (this.isEmpty) {
|
||||
return html`
|
||||
<div class="empty-state">
|
||||
${this.searchTerm
|
||||
? this._l10n("library-search-no-results", "No results")
|
||||
: this._l10n("library-downloads-empty", "No downloads found")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const groups = this.#groupByDate(slice);
|
||||
return html`
|
||||
${Array.from(groups).map(
|
||||
([key, downloads]) => html`
|
||||
<div class="library-date-separator">
|
||||
<span>${this._dayLabel(new Date(key))}</span>
|
||||
</div>
|
||||
${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";
|
||||
|
||||
return html`
|
||||
<div class="library-item">
|
||||
<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="${iconPath}"
|
||||
alt=""
|
||||
role="presentation"
|
||||
/>
|
||||
</div>
|
||||
<div class="library-item-label-container">
|
||||
<span class="library-item-label">${nameStr}</span>
|
||||
<span class="library-item-sublabel">${this.#getStatusLabel(dl)}</span>
|
||||
</div>
|
||||
<span class="library-item-time">
|
||||
${this._formatTime(this.#downloadTime(dl))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
#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.
|
||||
*/
|
||||
#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();
|
||||
if (term) {
|
||||
this._allItems = this.#allDownloads.filter((dl) =>
|
||||
this.#matchesSearchTerm(dl, term),
|
||||
);
|
||||
} else {
|
||||
this._allItems = [...this.#allDownloads];
|
||||
}
|
||||
}
|
||||
|
||||
#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;
|
||||
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
|
||||
/** Spaces section: Borgir man will do it :) */
|
||||
class ZenLibrarySpacesSection extends LibrarySection {
|
||||
static largeContent = true;
|
||||
static id = "spaces";
|
||||
|
||||
Reference in New Issue
Block a user