no-bug: Added history search

This commit is contained in:
mr. m
2026-05-10 15:54:12 +02:00
parent b5a251a92f
commit 7261eb1688
4 changed files with 870 additions and 120 deletions

View File

@@ -23,6 +23,52 @@ library-search-no-results = No results
library-filter-button = Filter
library-history-filter-all =
.label = All time
library-history-filter-today =
.label = Today
library-history-filter-yesterday =
.label = Yesterday
library-history-filter-last-7-days =
.label = Last 7 days
library-history-filter-last-30-days =
.label = Last 30 days
library-downloads-filter-all =
.label = All
library-downloads-filter-completed =
.label = Completed
library-downloads-filter-in-progress =
.label = In progress
library-downloads-filter-failed =
.label = Failed
library-downloads-filter-paused =
.label = Paused
library-item-context-open =
.label = Open
library-item-context-open-glance =
.label = Open in Glance
library-item-context-open-new-tab =
.label = Open in New Tab
library-item-context-open-new-window =
.label = Open in New Window
library-item-context-copy-url =
.label = Copy URL
library-item-context-delete-history =
.label = Forget About This Page
library-item-context-show-in-folder =
.label = Show in Folder
library-item-context-open-source =
.label = Open Source URL
library-item-context-remove =
.label = Remove from History
library-history-action-remove =
.tooltiptext = Forget About This Page
library-history-action-open-tab =
.tooltiptext = Open in New Tab
library-downloads-state-downloading = Downloading…
library-downloads-state-canceled = Canceled
library-downloads-state-failed = Failed

View File

@@ -9,6 +9,7 @@
<link rel="localization" href="browser/zen-menubar.ftl"/>
<link rel="localization" href="browser/zen-vertical-tabs.ftl"/>
<link rel="localization" href="browser/zen-folders.ftl"/>
<link rel="localization" href="browser/zen-library.ftl"/>
<link rel="localization" href="browser/zen-boosts.ftl"/>
<link rel="localization" href="browser/zen-live-folders.ftl"/>
</linkset>

View File

@@ -10,11 +10,13 @@ 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",
});
@@ -24,7 +26,32 @@ ChromeUtils.defineLazyGetter(
() => 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, {
@@ -67,6 +94,7 @@ class SearchSection extends LibrarySection {
inputValue: { type: String },
isEmpty: { type: Boolean },
isScrolledToTop: { type: Boolean },
activeFilter: { type: String },
};
#canDebug = false;
@@ -80,6 +108,9 @@ class SearchSection extends LibrarySection {
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();
@@ -156,17 +187,321 @@ class SearchSection extends LibrarySection {
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"
/>
<link rel="localization" href="browser/zen-library.ftl" />
<div class="search-bar">
<div class="search-urlbar">
<div class="search-urlbar-background"></div>
@@ -195,20 +530,30 @@ class SearchSection extends LibrarySection {
</button>
</div>
</div>
<button class="search-filter-button">
<img src="chrome://browser/skin/zen-icons/search-glass.svg" alt="" />
<label data-l10n-id="library-filter-button"></label>
</button>
${
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=${() => {
const el = this.renderRoot.querySelector(".search-results");
if (!el) {
return;
}
this.isScrolledToTop = el.scrollTop === 0;
}}
@scroll=${e => {
const el = e.currentTarget;
this.isScrolledToTop = el.scrollTop === 0;
this._onScroll?.(el);
}}
?scrolled-to-top=${this.isScrolledToTop}
>
${this.renderSearchResults()}
@@ -223,10 +568,14 @@ class SearchSection extends LibrarySection {
* 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;
#renderTask = null;
#fillScheduled = false;
_getRenderedSlice() {
return this._allItems.slice(0, this.#renderedItemCount);
@@ -234,47 +583,83 @@ class ProgressiveSearchSection extends SearchSection {
/** 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.#renderedItemCount = Math.min(
ProgressiveSearchSection.INITIAL_BATCH,
this._allItems.length
);
this.isEmpty = this._allItems.length === 0;
this.#scheduleNextBatch();
this.#scheduleViewportFill();
}
#scheduleNextBatch() {
if (this.#renderedItemCount >= this._allItems.length) {
/** 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.#renderTask = requestIdleCallback(() => {
this.#renderTask = null;
this.#renderedItemCount = Math.min(
this.#renderedItemCount + 100,
this._allItems.length
);
this.requestUpdate();
this.#scheduleNextBatch();
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();
}
});
}
#cancelTask() {
if (this.#renderTask) {
cancelIdleCallback(this.#renderTask);
this.#renderTask = null;
#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() {
// Cancel before super so no stale idle callback fires against cleared base state.
this.#cancelTask();
this._allItems = [];
super.disconnectedCallback();
}
@@ -378,13 +763,25 @@ class ZenLibraryHistorySection extends ProgressiveSearchSection {
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 dayStart = new Date(date).setHours(0, 0, 0, 0);
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;
@@ -399,13 +796,47 @@ class ZenLibraryHistorySection extends ProgressiveSearchSection {
}
this.log(
`#walkAndPopulate — ${this._allItems.length} items, search=${!!this.searchTerm}`
`#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 {
@@ -494,42 +925,128 @@ class ZenLibraryHistorySection extends ProgressiveSearchSection {
if (dayLabel !== lastLabel) {
lastLabel = dayLabel;
rows.push(html`
<label class="library-date-separator"> ${lastLabel} </label>
<label class="library-date-separator">${lastLabel}</label>
`);
}
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">
<label class="library-item-label">${v.title || v.url}</label>
<label class="library-item-sublabel"
>${
v.url?.replace(/^[\w-]+:\/\//, "").replace(/\/$/, "") || ""
}</label
>
</div>
<label class="library-item-time"
>${this._formatTime(v.date)}</label
>
</div>
</div>
</div>
`);
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);
},
},
];
}
}
/**
@@ -633,6 +1150,16 @@ class ZenLibraryDownloadsSection extends ProgressiveSearchSection {
this.requestUpdate();
}
_filters() {
return DOWNLOAD_FILTERS;
}
_onFilterChange(_id) {
this.#rebuildItems();
this._resetProgressiveRender();
this.requestUpdate();
}
renderSearchResults() {
const slice = this._getRenderedSlice();
@@ -667,32 +1194,121 @@ class ZenLibraryDownloadsSection extends ProgressiveSearchSection {
? `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">
<label class="library-item-label">${nameStr}</label>
<label class="library-item-sublabel"
>${this.#getStatusLabel(dl)}</label
>
</div>
<label class="library-item-time">
${this._formatTime(this.#downloadTime(dl))}
</label>
</div>
</div>
</div>
`;
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) {
@@ -723,12 +1339,40 @@ class ZenLibraryDownloadsSection extends ProgressiveSearchSection {
#rebuildItems() {
const term = this.searchTerm?.trim().toLowerCase();
if (term) {
this._allItems = this.#allDownloads.filter(dl =>
this.#matchesSearchTerm(dl, term)
);
} else {
this._allItems = [...this.#allDownloads];
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;
}
}

View File

@@ -114,7 +114,6 @@
gap: 4px;
margin: 8px 6px;
padding: 0;
box-sizing: border-box;
@media (-moz-platform: macos) {
font-size: 1rem; /* Slightly larger font on macOS */
@@ -131,7 +130,6 @@
margin-inline: 2px;
background: transparent;
border-radius: var(--border-radius-medium);
box-sizing: border-box;
}
.search-urlbar-background {
@@ -155,7 +153,6 @@
gap: 0;
padding-inline: var(--urlbar-icon-padding, 9px);
padding-block: var(--urlbar-padding-block, 4px);
box-sizing: border-box;
overflow: hidden;
& .search-icon {
@@ -243,7 +240,12 @@
fill-opacity: 0.5;
-moz-context-properties: fill, fill-opacity;
&:hover {
& label {
opacity: .8;
}
&:hover,
&[open] {
background: var(--zen-toolbar-element-bg-hover);
}
@@ -264,6 +266,7 @@
overflow-y: auto;
scrollbar-width: auto;
scrollbar-color: color-mix(in srgb, currentColor 35%, transparent 65%) transparent;
-moz-window-dragging: drag;
@media (-moz-platform: macos) {
font-size: 1.25rem; /* Slightly larger font on macOS */
@@ -308,6 +311,7 @@
border-radius: 0;
color: inherit;
color-scheme: unset;
cursor: pointer;
overflow: clip;
}
@@ -315,6 +319,7 @@
display: grid;
position: relative;
width: 100%;
-moz-window-dragging: no-drag;
}
.library-item-background {
@@ -338,8 +343,9 @@
position: relative;
align-items: center;
min-width: 0;
padding: 12px 10px;
padding: 0 10px;
overflow: clip;
height: 56px;
}
.library-item-icon-stack {
@@ -389,23 +395,76 @@
overflow: hidden;
}
/* unfinished */
.library-item-time {
.library-item-side {
display: flex;
flex-direction: column;
flex-shrink: 0;
margin-inline-start: calc(var(--toolbarbutton-inner-padding) - 0.5em); /* idk why this calc makes sense to me
but it is inconsistant so please fix it if you have an idea */
padding-inline-end: calc(var(--toolbarbutton-inner-padding) / 3);
font-size: x-small;
align-items: flex-end;
justify-content: center;
gap: 2px;
margin-inline-start: 8px;
padding-inline-end: 4px;
text-align: end;
white-space: nowrap;
opacity: 0.65;
display: none;
pointer-events: none;
}
.library-item-content:hover & {
display: flex;
.library-item-side-top {
font-size: 0.85em;
}
.library-item-side-bottom {
font-size: x-small;
opacity: 0.85;
}
.library-item-actions {
display: none;
flex-shrink: 0;
align-items: center;
gap: 4px;
margin-inline-start: 8px;
}
.library-item:hover .library-item-actions,
.library-item:focus-within .library-item-actions {
display: flex;
}
.library-item-action-button {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 6px;
appearance: none;
background: transparent;
border: none;
border-radius: calc(var(--border-radius-medium) - 6px);
color: inherit;
cursor: pointer;
fill: currentColor;
-moz-context-properties: fill, fill-opacity;
& img {
width: 18px;
height: 18px;
opacity: 0.75;
pointer-events: none;
}
&:hover {
background: color-mix(in srgb, currentColor 12%, transparent);
}
&:hover img {
opacity: 1;
}
}
/* unfinished */
.empty-state {
display: flex;
flex: 1;