diff --git a/locales/en-US/browser/browser/zen-library.ftl b/locales/en-US/browser/browser/zen-library.ftl index 53b517f66..d44155d64 100644 --- a/locales/en-US/browser/browser/zen-library.ftl +++ b/locales/en-US/browser/browser/zen-library.ftl @@ -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 diff --git a/src/browser/base/content/zen-locales.inc.xhtml b/src/browser/base/content/zen-locales.inc.xhtml index ed52e6fca..490dd1d8a 100644 --- a/src/browser/base/content/zen-locales.inc.xhtml +++ b/src/browser/base/content/zen-locales.inc.xhtml @@ -9,6 +9,7 @@ + diff --git a/src/zen/library/ZenLibrarySections.mjs b/src/zen/library/ZenLibrarySections.mjs index 333c5f042..289613c84 100644 --- a/src/zen/library/ZenLibrarySections.mjs +++ b/src/zen/library/ZenLibrarySections.mjs @@ -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` +
this._onItemClick(e, payload)} + @auxclick=${e => this._onItemClick(e, payload)} + @contextmenu=${e => this._onItemContextMenu(e, payload)} + > +
+
+
+
+ +
+
+ + +
+ ${ + sideTop || sideBottom + ? html` +
+ ${ + sideTop + ? html`` + : "" + } + ${ + sideBottom + ? html`` + : "" + } +
+ ` + : "" + } +
+ ${this._renderItemActions(payload).map( + action => html` + + ` + )} +
+
+
+
+ `; + } + + /** + * 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` - - + ${ + hasFilters + ? html` + + ` + : "" + }
{ - 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` - + `); } - - rows.push(html` -
-
-
-
-
- -
-
- - -
- -
-
-
- `); + 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` -
-
-
-
-
- -
-
- - -
- -
-
-
- `; + 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; } } diff --git a/src/zen/library/zen-library.css b/src/zen/library/zen-library.css index bb32a95fb..2cf4c373c 100644 --- a/src/zen/library/zen-library.css +++ b/src/zen/library/zen-library.css @@ -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;