From 2be85a2890f891bf6a8d8e7796a95468429e0bf0 Mon Sep 17 00:00:00 2001 From: JustAdumbPrsn <73780892+JustAdumbPrsn@users.noreply.github.com> Date: Fri, 13 Mar 2026 03:57:11 +0530 Subject: [PATCH] UI, search (clicking item does nothing) Signed-off-by: JustAdumbPrsn <73780892+JustAdumbPrsn@users.noreply.github.com> --- src/zen/library/ZenLibrarySections.mjs | 804 ++++++++++++++++++++++--- 1 file changed, 707 insertions(+), 97 deletions(-) diff --git a/src/zen/library/ZenLibrarySections.mjs b/src/zen/library/ZenLibrarySections.mjs index b33336a86..b74039260 100644 --- a/src/zen/library/ZenLibrarySections.mjs +++ b/src/zen/library/ZenLibrarySections.mjs @@ -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" /> - - - - +
${this.renderSearchResults()}
`; } } -// 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`
- ${lazy.l10n.formatValueSync("library-history-empty")} -
`; - } - 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` +
+ ${this.searchTerm + ? this._l10n("library-search-no-results", "No results") + : this._l10n("library-history-empty", "No history found")} +
+ `; + } + + 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` +
+ ${lastLabel} +
+ `); + } + + rows.push(html` +
+
+
+
+
+ +
+
+ ${v.title || v.url} + ${v.url} +
+ ${this._formatTime(v.date)} +
+
+
+ `); + } + + 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`
- ${lazy.l10n.formatValueSync("library-downloads-empty")} -
`; - } - 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` +
+ ${this.searchTerm + ? this._l10n("library-search-no-results", "No results") + : this._l10n("library-downloads-empty", "No downloads found")} +
+ `; + } + + const groups = this.#groupByDate(slice); + return html` + ${Array.from(groups).map( + ([key, downloads]) => html` +
+ ${this._dayLabel(new Date(key))} +
+ ${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` +
+
+
+
+
+ +
+
+ ${nameStr} + ${this.#getStatusLabel(dl)} +
+ + ${this._formatTime(this.#downloadTime(dl))} + +
+
+
+ `; + } + + #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";