UI, search (clicking item does nothing)

Signed-off-by: JustAdumbPrsn <73780892+JustAdumbPrsn@users.noreply.github.com>
This commit is contained in:
JustAdumbPrsn
2026-03-13 03:57:11 +05:30
committed by GitHub
parent 9301e1b64c
commit 2be85a2890

View File

@@ -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";