feat: Live folders, p=#11921

Co-authored-by: Slowlife01 <slowlife1165@gmail.com>
Co-authored-by: mr. m <91018726+mr-cheffy@users.noreply.github.com>
Co-authored-by: reizumi <reizumichan@protonmail.com>
This commit is contained in:
Slowlife
2026-02-21 06:17:05 +07:00
committed by GitHub
parent 97078b42ac
commit c28d6520d6
43 changed files with 2421 additions and 22 deletions

View File

@@ -0,0 +1,98 @@
# 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/.
zen-live-folder-options =
.label = Live Folder Options
zen-live-folder-last-fetched =
.label = Last fetch: { $time }
zen-live-folder-refresh =
.label = Refresh
zen-live-folder-github-option-author-self =
.label = Created by Me
zen-live-folder-github-option-assigned-self =
.label = Assigned to Me
zen-live-folder-github-option-review-requested =
.label = Review Requests
zen-live-folder-type-rss =
.label = RSS Feed
zen-live-folder-option-fetch-interval =
.label = Fetch Interval
zen-live-folder-fetch-interval-mins =
.label = { $mins ->
[one] 1 minute
*[other] { $mins } minutes
}
zen-live-folder-fetch-interval-hours =
.label = { $hours ->
[one] 1 hour
*[other] { $hours } hours
}
zen-live-folder-rss-option-time-range =
.label = Time Range
zen-live-folder-time-range-hours =
.label = { $hours ->
[one] Last hour
*[other] Last { $hours } hours
}
zen-live-folder-time-range-all-time =
.label = All time
zen-live-folder-time-range-days =
.label = { $days ->
[one] Last day
*[other] Last { $days } days
}
zen-live-folder-rss-option-item-limit =
.label = Item Limit
zen-live-folder-rss-option-feed-url =
.label = Feed URL
zen-live-folder-rss-prompt-feed-url = Please enter the feed URL
zen-live-folder-rss-option-item-limit-num =
.label = { $limit } items
zen-live-folder-failed-fetch =
.label = Failed to update
.tooltiptext = Failed to update. Try again.
zen-live-folder-github-no-auth =
.label = Not signed in to GitHub
.tooltiptext = Sign back in to GitHub.
zen-live-folder-github-no-filter =
.label = Filter is not set
.tooltiptext = No filter set, nothing will be fetched.
zen-live-folder-rss-invalid-url-title = Failed to create the Live Folder
zen-live-folder-rss-invalid-url-description = The feed URL is invalid. Check the address and try again
zen-live-folder-github-option-repo-filter =
.label = Repositories
zen-live-folder-github-option-repo =
.label = { $repo }
zen-live-folder-github-pull-requests =
.label = Pull Requests
zen-live-folder-github-issues =
.label = Issues
zen-live-folder-github-option-repo-list-note =
.label = This list is generated based on your currently active pull requests.

View File

@@ -42,4 +42,9 @@ tabbrowser-reset-pin-button =
[one] Reset and pin tab
*[other] Reset and pin { $tabCount } tabs
}
tab-reset-pin-label = Back to pinned url
zen-tab-sublabel =
{ $tabSubtitle ->
[zen-default-pinned] Back to pinned url
*[other] { $tabSubtitle }
}

View File

@@ -13,6 +13,9 @@ zen-panel-ui-workspaces-create =
zen-panel-ui-folder-create =
.label = Create Folder
zen-panel-ui-live-folder-create =
.label = Live Folder
zen-panel-ui-new-empty-split =
.label = New Split

View File

@@ -63,6 +63,10 @@
value: false
locked: true
- name: browser.search.serpEventTelemetryCategorization.enabled
value: false
locked: true
- name: browser.newtabpage.activity-stream.telemetry
value: false
locked: true

View File

@@ -51,3 +51,4 @@
<script type="module" src="chrome://browser/content/zen-components/ZenFolders.mjs"></script>
<script type="module" src="chrome://browser/content/zen-components/ZenDownloadAnimation.mjs"></script>
<script type="module" src="chrome://browser/content/zen-components/ZenEmojiPicker.mjs"></script>
<script type="module" src="chrome://browser/content/zen-components/ZenLiveFoldersUI.mjs"></script>

View File

@@ -18,3 +18,4 @@
#include ../../../zen/images/jar.inc.mn
#include ../../../zen/vendor/jar.inc.mn
#include ../../../zen/fonts/jar.inc.mn
#include ../../../zen/live-folders/jar.inc.mn

View File

@@ -63,4 +63,6 @@
<command id="cmd_zenCloseUnpinnedTabs" />
<command id="cmd_zenNewNavigatorUnsynced" />
<command id="cmd_zenNewLiveFolder" />
</commandset>

View File

@@ -9,4 +9,5 @@
<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-live-folders.ftl"/>
</linkset>

View File

@@ -3,6 +3,23 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
<menupopup id="zenCreateNewPopup">
<menu data-l10n-id="zen-panel-ui-live-folder-create" id="zen-panel-ui-live-folder-create">
<menupopup>
<menuitem
data-l10n-id="zen-live-folder-github-pull-requests"
command="cmd_zenNewLiveFolder"
image="chrome://browser/content/zen-images/favicons/github.svg" />
<menuitem
data-l10n-id="zen-live-folder-github-issues"
command="cmd_zenNewLiveFolder"
image="chrome://browser/content/zen-images/favicons/github.svg" />
<menuitem
data-l10n-id="zen-live-folder-type-rss"
command="cmd_zenNewLiveFolder"
image="chrome://browser/skin/zen-icons/selectable/logo-rss.svg"/>
</menupopup>
</menu>
<menuseparator/>
<menuitem data-l10n-id="zen-panel-ui-workspaces-create" command="cmd_zenOpenWorkspaceCreation" image="chrome://browser/skin/zen-icons/duplicate-tab.svg" />
<menuitem data-l10n-id="zen-panel-ui-folder-create" command="cmd_zenOpenFolderCreation" image="chrome://browser/skin/zen-icons/folder.svg" />
<menuseparator/>
@@ -35,6 +52,12 @@
</menupopup>
<menupopup id="zenFolderActions">
<menu id="context_zenLiveFolderOptions"
data-l10n-id="zen-live-folder-options"
hidden="true">
<menupopup />
</menu>
<menuseparator id="live-folder-separator" hidden="true"/>
<menuitem id="context_zenFolderRename" data-l10n-id="zen-folders-panel-rename-folder"/>
<menuitem id="context_zenFolderChangeIcon" data-l10n-id="tab-context-zen-edit-icon"/>
<menuseparator />

View File

@@ -1,8 +1,8 @@
diff --git a/browser/components/sessionstore/TabState.sys.mjs b/browser/components/sessionstore/TabState.sys.mjs
index 82721356d191055bec0d4b0ca49e481221988801..68437e6f9fa54fc75ca9e24d738e8afcd0ea22f8 100644
index 82721356d191055bec0d4b0ca49e481221988801..e01904a6ea73e068c236adecbac6a97adedb2bd3 100644
--- a/browser/components/sessionstore/TabState.sys.mjs
+++ b/browser/components/sessionstore/TabState.sys.mjs
@@ -85,7 +85,24 @@ class _TabState {
@@ -85,7 +85,25 @@ class _TabState {
tabData.groupId = tab.group.id;
}
@@ -19,6 +19,7 @@ index 82721356d191055bec0d4b0ca49e481221988801..68437e6f9fa54fc75ca9e24d738e8afc
+ tabData.zenIsGlance = tab.hasAttribute("zen-glance-tab");
+ tabData._zenPinnedInitialState = tab._zenPinnedInitialState;
+ tabData._zenIsActiveTab = tab._zenContentsVisible;
+ tabData.zenLiveFolderItemId = tab.getAttribute("zen-live-folder-item-id");
+
tabData.searchMode = tab.ownerGlobal.gURLBar.getSearchMode(browser, true);
+ if (tabData.searchMode?.source === tab.ownerGlobal.UrlbarUtils.RESULT_SOURCE.ZEN_ACTIONS) {

View File

@@ -1,5 +1,5 @@
diff --git a/browser/components/tabbrowser/content/tab.js b/browser/components/tabbrowser/content/tab.js
index 836bee14d2b63604688ebe477a5d915a5e99b305..a2aca4443ee1c909149739d002028c6c481aa62a 100644
index 836bee14d2b63604688ebe477a5d915a5e99b305..a675aed711560b4a44604fc17478cffa7fb68439 100644
--- a/browser/components/tabbrowser/content/tab.js
+++ b/browser/components/tabbrowser/content/tab.js
@@ -21,6 +21,7 @@
@@ -14,7 +14,7 @@ index 836bee14d2b63604688ebe477a5d915a5e99b305..a2aca4443ee1c909149739d002028c6c
<hbox class="tab-secondary-label">
<label class="tab-icon-sound-label tab-icon-sound-pip-label" data-l10n-id="browser-tab-audio-pip" role="presentation"/>
</hbox>
+ <label class="tab-reset-pin-label" data-l10n-id="tab-reset-pin-label" role="presentation"/>
+ <label class="zen-tab-sublabel" data-l10n-id="zen-tab-sublabel" data-l10n-args='{"tabSubtitle": "zen-default-pinned"}' role="presentation"/>
</vbox>
<image class="tab-note-icon" role="presentation"/>
<image class="tab-close-button close-icon" role="button" data-l10n-id="tabbrowser-close-tabs-button" data-l10n-args='{"tabCount": 1}' keyNav="false"/>

View File

@@ -0,0 +1,5 @@
#filter dumbComments emptyLines substitution
# 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/.
<svg fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><path d="M256 32C132.3 32 32 134.9 32 261.7c0 101.5 64.2 187.5 153.2 217.9a17.56 17.56 0 003.8.4c8.3 0 11.5-6.1 11.5-11.4 0-5.5-.2-19.9-.3-39.1a102.4 102.4 0 01-22.6 2.7c-43.1 0-52.9-33.5-52.9-33.5-10.2-26.5-24.9-33.6-24.9-33.6-19.5-13.7-.1-14.1 1.4-14.1h.1c22.5 2 34.3 23.8 34.3 23.8 11.2 19.6 26.2 25.1 39.6 25.1a63 63 0 0025.6-6c2-14.8 7.8-24.9 14.2-30.7-49.7-5.8-102-25.5-102-113.5 0-25.1 8.7-45.6 23-61.6-2.3-5.8-10-29.2 2.2-60.8a18.64 18.64 0 015-.5c8.1 0 26.4 3.1 56.6 24.1a208.21 208.21 0 01112.2 0c30.2-21 48.5-24.1 56.6-24.1a18.64 18.64 0 015 .5c12.2 31.6 4.5 55 2.2 60.8 14.3 16.1 23 36.6 23 61.6 0 88.2-52.4 107.6-102.3 113.3 8 7.1 15.2 21.1 15.2 42.5 0 30.7-.3 55.5-.3 63 0 5.4 3.1 11.5 11.4 11.5a19.35 19.35 0 004-.4C415.9 449.2 480 363.1 480 261.7 480 134.9 379.7 32 256 32z"/></svg>

View File

@@ -18,6 +18,11 @@
border-radius: 100% !important;
}
#zenCreateNewPopup menuitem {
-moz-context-properties: fill, fill-opacity;
fill: currentColor;
}
#back-button {
list-style-image: url("back.svg") !important;
}

View File

@@ -437,6 +437,7 @@
* skin/classic/browser/zen-icons/selectable/lightning.svg (../shared/zen-icons/common/selectable/lightning.svg)
* skin/classic/browser/zen-icons/selectable/location.svg (../shared/zen-icons/common/selectable/location.svg)
* skin/classic/browser/zen-icons/selectable/lock-closed.svg (../shared/zen-icons/common/selectable/lock-closed.svg)
* skin/classic/browser/zen-icons/selectable/logo-github.svg (../shared/zen-icons/common/selectable/logo-github.svg)
* skin/classic/browser/zen-icons/selectable/logo-rss.svg (../shared/zen-icons/common/selectable/logo-rss.svg)
* skin/classic/browser/zen-icons/selectable/logo-usd.svg (../shared/zen-icons/common/selectable/logo-usd.svg)
* skin/classic/browser/zen-icons/selectable/mail.svg (../shared/zen-icons/common/selectable/mail.svg)

View File

@@ -2,4 +2,4 @@
# 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/.
<svg fill="none" stroke="context-fill" stroke-width="1.5" stroke-opacity="context-fill-opacity" height="18" width="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><path d="m 9,13.569 c -0.552,0 -1,-0.449 -1,-1 0,-0.551 0.448,-1 1,-1 0.552,0 1,0.449 1,1 0,0.551 -0.448,1 -1,1 z M 9,6.5 V 10 M 7.638,3.495 2.213,12.891 c -0.605,1.048 0.151,2.359 1.362,2.359 h 10.85 c 1.211,0 1.967,-1.31 1.362,-2.359 L 10.362,3.495 C 9.757,2.447 8.243,2.447 7.638,3.495 Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" height="18" width="18" viewBox="0 0 18 18"><g stroke-linecap="round" stroke-width="1.5" fill="none" stroke="context-fill" stroke-opacity="context-fill-opacity" stroke-linejoin="round"><path d="M7.638,3.495L2.213,12.891c-.605,1.048,.151,2.359,1.362,2.359H14.425c1.211,0,1.967-1.31,1.362-2.359L10.362,3.495c-.605-1.048-2.119-1.048-2.724,0Z"></path><line x1="9" y1="6.5" x2="9" y2="10"></line><path d="M9,13.569c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z" fill="context-fill" data-stroke="none" stroke="none"></path></g></svg>

View File

@@ -4,6 +4,13 @@
# note: you need to be in the same directory as the script to run it
# a list of SVG files that we should ignore when exporting, because
# they render poorly when optimized, and we don't use them in many places
# so the cost of optimizing them is higher than the benefit of having optimized SVGs
do_not_optimize=(
"security-broken.svg"
)
if [ $(basename $PWD) != "zen-icons" ]; then
echo "You need to be in the zen-icons directory to run this script"
exit 1
@@ -35,6 +42,11 @@ merge_svg_paths() {
stroke_attr=$(grep -o 'stroke="[^"]*"' "$file" | head -n 1)
stroke_width_attr=$(grep -o 'stroke-width="[^"]*"' "$file" | head -n 1)
stroke_opacity_attr=$(grep -o 'stroke-opacity="[^"]*"' "$file" | head -n 1)
# Check if the file is in the do_not_optimize list
if [[ " ${do_not_optimize[@]} " =~ " $(basename $file) " ]]; then
echo "Skipping optimization for $file"
return
fi
# Use inkscape to merge all paths into one
inkscape "$file" --actions="select-all;object-to-path;select-all;path-combine" --export-plain-svg --export-filename="${temp_file}"
# optimize the svg

View File

@@ -15,3 +15,4 @@ category app-startup nsBrowserGlue @mozilla.org/browser/browserglue;1 applicatio
#include common/Components.manifest
#include sessionstore/SessionComponents.manifest
#include live-folders/LiveFoldersComponents.manifest

View File

@@ -17,6 +17,9 @@ class ZenSessionStore extends nsZenPreloadedFeature {
if (tabData.zenWorkspace) {
tab.setAttribute("zen-workspace-id", tabData.zenWorkspace);
}
if (tabData.zenLiveFolderItemId) {
tab.setAttribute("zen-live-folder-item-id", tabData.zenLiveFolderItemId);
}
// Keep for now, for backward compatibility for window sync to work.
if (tabData.zenSyncId || tabData.zenPinnedId) {
tab.setAttribute("id", tabData.zenSyncId || tabData.zenPinnedId);

View File

@@ -58,6 +58,7 @@ window.gZenUIManager = {
gZenMediaController.init();
gZenVerticalTabsManager.init();
gZenLiveFoldersUI.init();
this._initCreateNewPopup();
this._debloatContextMenus();

View File

@@ -130,6 +130,13 @@ document.addEventListener(
case "cmd_zenNewNavigatorUnsynced":
OpenBrowserWindow({ zenSyncedWindow: false });
break;
case "cmd_zenNewLiveFolder": {
const { ZenLiveFoldersManager } = ChromeUtils.importESModule(
"resource:///modules/zen/ZenLiveFoldersManager.sys.mjs"
);
ZenLiveFoldersManager.handleEvent(event);
break;
}
default:
gZenGlanceManager.handleMainCommandSet(event);
if (event.target.id.startsWith("cmd_zenWorkspaceSwitch")) {

View File

@@ -930,6 +930,22 @@
gZenPinnedTabManager.removeTabContainersDragoverClass();
}
#canDropIntoFolder(dropElement, draggedTab) {
let folder = dropElement?.classList.contains("tab-group-label-container")
? dropElement.parentElement
: dropElement?.group;
if (!folder?.isZenFolder) {
return true;
}
if (folder.isLiveFolder) {
const liveFolderItemId = draggedTab.getAttribute("zen-live-folder-item-id");
if (!liveFolderItemId || !liveFolderItemId.startsWith(`${folder.id}:`)) {
return false;
}
}
return true;
}
// eslint-disable-next-line complexity
#applyDragoverIndicator(event, dropElement, movingTabs, draggedTab) {
const separation = 4;
@@ -998,6 +1014,15 @@
(overlapPercent > 1 - threshold &&
(possibleFolderElement.collapsed ||
possibleFolderElement.childGroupsAndTabs.length < 2)));
if (
canHightlightGroup &&
!dropIntoFolder &&
!this.#canDropIntoFolder(dropElement, draggedTab)
) {
this.clearDragOverVisuals();
dropElement = null;
return [dropElement, dropBefore];
}
if (
isTabGroupLabel(draggedTab) &&
draggedTab.group?.isZenFolder &&

View File

@@ -2,6 +2,11 @@
// 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/.
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenLiveFoldersManager: "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs",
});
export class nsZenFolder extends MozTabbrowserTabGroup {
#initialized = false;
@@ -262,7 +267,12 @@ export class nsZenFolder extends MozTabbrowserTabGroup {
on_click(event) {
if (event.target === this.resetButton) {
event.stopPropagation();
this.unloadAllTabs(event);
if (event.target.hasAttribute("live-folder-action")) {
lazy.ZenLiveFoldersManager.handleEvent(event);
} else {
this.unloadAllTabs(event);
}
return;
}
super.on_click(event);

View File

@@ -91,6 +91,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
return;
}
this.#lastFolderContextMenu = folder;
gZenLiveFoldersUI.buildContextMenu(folder);
const newSubfolderItem = document.getElementById("context_zenFolderNewSubfolder");
newSubfolderItem.setAttribute(
@@ -241,7 +242,24 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
}
if (group.collapsed && !this._sessionRestoring) {
group.collapsed = group.hasAttribute("has-active");
if (group.isLiveFolder) {
if (!group.hasAttribute("has-active")) {
let groupStart = group.groupStartElement;
let marginTop = groupStart.style.marginTop ? parseInt(groupStart.style.marginTop) : 0;
if (marginTop < 0) {
groupStart.style.marginTop = `${marginTop + 4}px`;
}
}
tab.setAttribute("folder-active", "true");
group.setAttribute("has-active", "true");
group.groupContainer.removeAttribute("hidden");
group.activeTabs = [...new Set([...group.activeTabs, tab])].sort(
(a, b) => a._tPos > b._tPos
);
} else {
group.collapsed = group.hasAttribute("has-active");
}
}
}
@@ -576,6 +594,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
folder.label = options.label || "New Folder";
folder.saveOnWindowClose = !!options.saveOnWindowClose;
folder.color = "zen-workspace-color";
folder.isLiveFolder = options.isLiveFolder;
folder.setAttribute("zen-workspace-id", options.workspaceId || gZenWorkspaces.activeWorkspace);
@@ -1022,6 +1041,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
prevSiblingInfo,
emptyTabIds: emptyFolderTabs,
userIcon: userIcon?.getAttribute("href"),
isLiveFolder: folder.isLiveFolder,
// note: We shouldn't be using the workspace-id anywhere, we are just
// remembering it for the pinned tabs manager to use it later.
workspaceId: folder.getAttribute("zen-workspace-id"),
@@ -1061,6 +1081,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
pinned: folderData.pinned,
saveOnWindowClose: folderData.saveOnWindowClose,
workspaceId: folderData.workspaceId,
isLiveFolder: folderData.isLiveFolder,
});
folder.setAttribute("id", folderData.id);
workingData.node = folder;
@@ -1131,7 +1152,9 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
}
gBrowser.tabContainer._invalidateCachedTabs();
this._sessionRestoring = false;
setTimeout(() => {
delete this._sessionRestoring;
}, 0);
}
/**
@@ -1254,6 +1277,10 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
return heightShift;
}
get #folderAnimationDuration() {
return this._sessionRestoring ? 0 : 0.12;
}
async animateCollapse(group) {
this.cancelPopupTimer();
@@ -1309,11 +1336,13 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
});
}
let duration = this.#folderAnimationDuration;
animations.push(
...this.#createAnimation(
itemsToHide,
{ opacity: [1, 0], height: ["auto", 0] },
{ duration: 0.12, ease: "easeInOut" }
{ duration, ease: "easeInOut" }
),
...this.updateFolderIcon(group),
...this.#createAnimation(
@@ -1321,7 +1350,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
{
marginTop: -(collapsedHeight + 4 * (selectedTabs.length === 0 ? 1 : 0)),
},
{ duration: 0.12, ease: "easeInOut" }
{ duration, ease: "easeInOut" }
)
);
@@ -1429,16 +1458,18 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
group.activeTabs = [];
};
let duration = this.#folderAnimationDuration;
animations.push(
...this.#createAnimation(
itemsToShow,
{ opacity: "", height: "" },
{ duration: 0.12, ease: "easeInOut" }
{ duration, ease: "easeInOut" }
),
...this.#createAnimation(
itemsToHide,
{ opacity: 0, height: 0 },
{ duration: 0.12, ease: "easeInOut" }
{ duration, ease: "easeInOut" }
),
...this.updateFolderIcon(group),
...this.#createAnimation(
@@ -1446,7 +1477,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
{
marginTop: 0,
},
{ duration: 0.12, ease: "easeInOut" },
{ duration, ease: "easeInOut" },
afterMarginTop
)
);

View File

@@ -192,6 +192,12 @@ zen-folder {
max-width: 100% !important;
}
}
.tab-reset-button[live-folder-action] {
list-style-image: url("chrome://browser/skin/zen-icons/security-broken.svg");
display: flex;
opacity: 1;
}
}
:root[zen-sidebar-expanded] &[has-active] > .tab-group-label-container {
@@ -207,7 +213,7 @@ zen-folder {
}
zen-workspace[collapsedpinnedtabs] .zen-workspace-pinned-tabs-section,
zen-folder[collapsed]:not([has-active]) > .tab-group-container {
zen-folder[collapsed] > .tab-group-container {
overflow-y: clip;
}

View File

@@ -0,0 +1,5 @@
# 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/.
category browser-window-delayed-startup resource:///modules/zen/ZenLiveFoldersManager.sys.mjs ZenLiveFoldersManager.init

View File

@@ -0,0 +1,337 @@
// 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/.
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
setTimeout: "resource://gre/modules/Timer.sys.mjs",
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
requestIdleCallback: "resource://gre/modules/Timer.sys.mjs",
cancelIdleCallback: "resource://gre/modules/Timer.sys.mjs",
NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
NetworkHelper: "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs",
});
export class nsZenLiveFolderProvider {
#timerHandle = null;
#idleCallbackHandle = null;
constructor({ id, manager, state }) {
this.id = id;
this.manager = manager;
this.state = { ...state };
}
fetchItems() {
throw new Error("Unimplemented");
}
getMetadata() {
throw new Error("Unimplemented");
}
async refresh() {
this.stop();
const result = await this.#internalFetch();
this.start();
return result;
}
start() {
const now = Date.now();
const lastFetched = this.state.lastFetched;
const interval = this.state.interval;
const timeSinceLast = now - lastFetched;
let delay = interval - timeSinceLast;
if (delay <= 0) {
delay = 0;
}
this.#scheduleNext(delay);
}
stop() {
if (this.#timerHandle) {
lazy.clearTimeout(this.#timerHandle);
this.#timerHandle = null;
}
if (this.#idleCallbackHandle) {
lazy.cancelIdleCallback(this.#idleCallbackHandle);
this.#idleCallbackHandle = null;
}
}
#scheduleNext(delay) {
if (this.#timerHandle) {
lazy.clearTimeout(this.#timerHandle);
}
this.#timerHandle = lazy.setTimeout(() => {
const fetchWhenIdle = () => {
this.#internalFetch();
this.#idleCallbackHandle = null;
};
this.#idleCallbackHandle = lazy.requestIdleCallback(fetchWhenIdle);
this.#scheduleNext(this.state.interval);
}, delay);
}
async #internalFetch() {
try {
const items = await this.fetchItems();
this.state.lastFetched = Date.now();
this.requestSave();
this.manager.onLiveFolderFetch(this, items);
return items;
} catch {}
return null;
}
get options() {
return [];
}
onOptionTrigger(option) {
const key = option.getAttribute("option-key");
switch (key) {
case "refresh": {
this.refresh();
break;
}
case "setInterval": {
const intervalMs = Number.parseInt(option.getAttribute("option-value"));
if (intervalMs > 0) {
this.state.interval = intervalMs;
this.requestSave();
this.stop();
this.start();
}
break;
}
}
}
onActionButtonClick(errorId) {
switch (errorId) {
case "zen-live-folder-failed-fetch": {
this.refresh();
break;
}
}
}
requestSave() {
this.manager.saveState();
}
fetch(url, { maxContentLength = 5 * 1024 * 1024 } = {}) {
const uri = lazy.NetUtil.newURI(url);
// TODO: Support userContextId when fetching, it should be inherited from the folder's
// current space context ID.
let userContextId = 0;
let folder = this.manager.getFolderForLiveFolder(this);
if (folder) {
let space = folder.ownerGlobal.gZenWorkspaces.getWorkspaceFromId(
folder.getAttribute("zen-workspace-id")
);
if (space) {
userContextId = space.containerTabId || 0;
}
}
const principal = Services.scriptSecurityManager.createContentPrincipal(uri, {
userContextId,
});
const securityFlags =
Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT |
Ci.nsILoadInfo.SEC_COOKIES_INCLUDE;
const channel = Services.io
.newChannelFromURI(
uri,
this.manager.window.document,
principal,
principal,
securityFlags,
Ci.nsIContentPolicy.TYPE_DOCUMENT
)
.QueryInterface(Ci.nsIHttpChannel);
let httpStatus = null;
let contentType = "";
let headerCharset = null;
const { promise, resolve, reject } = Promise.withResolvers();
const byteChunks = [];
let totalLength = 0;
channel.asyncOpen({
onDataAvailable: (request, stream, _offset, count) => {
totalLength += count;
if (totalLength > maxContentLength) {
request.cancel(Cr.NS_ERROR_FILE_TOO_BIG);
} else {
byteChunks.push(lazy.NetUtil.readInputStream(stream, count));
}
},
onStartRequest: (request) => {
const http = request.QueryInterface(Ci.nsIHttpChannel);
try {
httpStatus = http.responseStatus;
} catch (ex) {
httpStatus = null;
}
try {
contentType = http.getResponseHeader("content-type");
} catch (ex) {}
if (contentType && !lazy.NetworkHelper.isTextMimeType(contentType.split(";")[0].trim())) {
request.cancel(Cr.NS_ERROR_FILE_UNKNOWN_TYPE);
}
// Save charset without quotes or spaces for TextDecoder
const match = contentType.match(/charset=["' ]*([^;"' ]+)/i);
if (match) {
headerCharset = match[1];
}
// Enforce max length if provided by server
try {
const headerLen = Number(http.getResponseHeader("content-length"));
if (Number.isFinite(headerLen) && headerLen > maxContentLength) {
request.cancel(Cr.NS_ERROR_FILE_TOO_BIG);
}
} catch (ex) {}
},
onStopRequest: (_request, status) => {
if (!Components.isSuccessCode(status)) {
reject(Components.Exception("Failed to fetch document", status));
return;
}
const bytes = new Uint8Array(totalLength);
let writeOffset = 0;
for (const chunk of byteChunks) {
bytes.set(new Uint8Array(chunk), writeOffset);
writeOffset += chunk.byteLength;
}
let effectiveCharset = "utf-8";
const mimeType = contentType ? contentType.split(";")[0].trim().toLowerCase() : "";
if (mimeType === "text/html") {
effectiveCharset = this.sniffCharset(bytes, headerCharset);
} else if (headerCharset) {
const norm = this.normalizeAndValidateEncodingLabel(headerCharset);
if (norm) {
effectiveCharset = norm;
}
}
let decoded;
try {
decoded = new TextDecoder(effectiveCharset).decode(bytes);
} catch (e) {
decoded = new TextDecoder("utf-8").decode(bytes);
}
resolve({ text: decoded, status: httpStatus, contentType });
},
});
return promise;
}
/**
* Sniff an effective charset for the given response bytes using the HTML standard's precedence:
* 1) Byte Order Mark (BOM)
* 2) <meta charset> or http-equiv in the first 8KB of the document
* 3) HTTP Content-Type header charset (if provided and valid)
* 4) Default to utf-8
*
* @param {Uint8Array} bytes - The raw response bytes.
* @param {string} headerCharset - The charset from the Content-Type header.
* @returns {string} A validated, effective charset label for TextDecoder.
*/
sniffCharset(bytes, headerCharset = "") {
// 1. BOM detection (highest priority)
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
return "utf-8";
}
if (bytes.length >= 2) {
if (bytes[0] === 0xfe && bytes[1] === 0xff) {
return "utf-16be";
}
if (bytes[0] === 0xff && bytes[1] === 0xfe) {
return "utf-16le";
}
}
// 2. Scan the first 8KB for a meta-declared charset. This is checked before
// the HTTP header as a heuristic for misconfigured servers where the HTML
// is more likely to be correct.
try {
const headLen = Math.min(bytes.length, 8192);
const head = new TextDecoder("windows-1252").decode(bytes.subarray(0, headLen));
const metaCharsetRegex = /<meta\s+charset\s*=\s*["']?([a-z0-9_-]+)/i;
let match = head.match(metaCharsetRegex);
if (!match) {
const httpEquivRegex =
/<meta\s+http-equiv\s*=\s*["']?content-type["']?[^>]*content\s*=\s*["'][^"']*charset\s*=\s*([a-z0-9_-]+)/i;
match = head.match(httpEquivRegex);
}
if (match && match[1]) {
const norm = this.normalizeAndValidateEncodingLabel(match[1]);
if (norm) {
return norm;
}
}
} catch (e) {
// Ignore errors during meta scan and fall through.
}
// 3. Use charset from HTTP header if it's valid.
if (headerCharset) {
const norm = this.normalizeAndValidateEncodingLabel(headerCharset);
if (norm) {
return norm;
}
}
// 4. Default to UTF-8 if no other charset is found.
return "utf-8";
}
/**
* Normalizes a charset label and validates it is supported by TextDecoder.
*
* @param {string} label - The raw encoding label from headers or meta tags.
* @returns {string|null} The normalized, validated label, or null if invalid.
*/
normalizeAndValidateEncodingLabel(label) {
const l = (label || "").trim();
if (!l) {
return null;
}
try {
// TextDecoder constructor handles aliases and validation.
return new TextDecoder(l).encoding;
} catch (e) {
// The label was invalid or unsupported.
}
return null;
}
}

View File

@@ -0,0 +1,506 @@
// 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/.
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs",
ZenWindowSync: "resource:///modules/zen/ZenWindowSync.sys.mjs",
});
ChromeUtils.defineLazyGetter(
lazy,
"l10n",
() => new Localization(["browser/zen-live-folders.ftl"])
);
const DEFAULT_FETCH_INTERVAL = 30 * 60 * 1000;
const providers = [
{
path: "resource:///modules/zen/RssLiveFolder.sys.mjs",
module: "nsRssLiveFolderProvider",
},
{
path: "resource:///modules/zen/GithubLiveFolder.sys.mjs",
module: "nsGithubLiveFolderProvider",
},
];
class nsZenLiveFoldersManager {
#isInitialized = false;
#saveFilename = "zen-live-folders.jsonlz4";
#file = null;
constructor() {
this.liveFolders = new Map();
this.registry = new Map();
this.dismissedItems = new Set();
this.folderRefs = new WeakMap();
}
get window() {
return lazy.ZenWindowSync.firstSyncedWindow;
}
async init() {
if (this.#isInitialized) {
return;
}
for (const provider of providers) {
const module = ChromeUtils.importESModule(provider.path, { global: "current" });
const ProviderClass = module[provider.module];
this.registry.set(ProviderClass.type, ProviderClass);
}
await this.#restoreState();
this.#initEventListeners();
this.#isInitialized = true;
}
// Event Handling
// --------------
#initEventListeners() {
lazy.ZenWindowSync.addSyncHandler(this.handleEvent.bind(this));
}
handleEvent(aEvent) {
switch (aEvent.type) {
case "TabUngrouped":
case "TabClose": {
this.#onTabDismiss(aEvent);
break;
}
case "TabGroupRemoved": {
this.#onTabGroupRemoved(aEvent);
break;
}
case "command": {
this.#onCommand(aEvent);
break;
}
case "click": {
this.#onActionButtonClick(aEvent);
break;
}
}
}
#onCommand(event) {
switch (event.target.id) {
case "cmd_zenNewLiveFolder": {
const target = event.sourceEvent.target;
switch (target.getAttribute("data-l10n-id")) {
case "zen-live-folder-github-pull-requests": {
this.createFolder("github:pull-requests");
break;
}
case "zen-live-folder-github-issues": {
this.createFolder("github:issues");
break;
}
case "zen-live-folder-type-rss": {
this.createFolder("rss");
break;
}
}
}
}
}
#onTabDismiss(event) {
const itemIdAttr = "zen-live-folder-item-id";
const itemId =
event.target.getAttribute(itemIdAttr) || event.detail?.getAttribute?.(itemIdAttr);
if (itemId) {
if (event.type === "TabUngrouped") {
const target = event.detail;
target.removeAttribute("zen-live-folder-item-id");
const showSublabel = target.hasAttribute("zen-show-sublabel");
if (showSublabel) {
target.removeAttribute("zen-show-sublabel");
const label = target.querySelector(".zen-tab-sublabel");
this.window.document.l10n.setArgs(label, {
tabSubtitle: "zen-default-pinned",
});
}
}
this.dismissedItems.add(itemId);
this.saveState();
}
}
#onActionButtonClick(event) {
const liveFolderId = event.target.getAttribute("live-folder-action");
this.getFolder(liveFolderId)?.onActionButtonClick(event.target.getAttribute("data-l10n-id"));
}
#onTabGroupRemoved(event) {
const tabGroup = event.target;
if (tabGroup.isLiveFolder) {
this.deleteFolder(tabGroup.id, false);
}
}
// Public API
// ----------
getFolder(id) {
return this.liveFolders.get(id);
}
async createFolder(type) {
const [provider, providerType] = type.split(":");
let ProviderClass = this.registry.get(provider);
if (!ProviderClass) {
return -1;
}
let url;
let label;
let icon;
switch (provider) {
case "rss": {
url = await ProviderClass.promptForFeedUrl(this.window);
if (!url) {
return -1;
}
const metadata = await ProviderClass.getMetadata(url, this.window);
label = metadata.label;
icon = metadata.icon;
break;
}
case "github": {
const [message] = await lazy.l10n.formatMessages([
{ id: `zen-live-folder-github-${providerType}` },
]);
label = message.attributes[0].value;
icon = "chrome://browser/skin/zen-icons/selectable/logo-github.svg";
break;
}
}
const folder = this.window.gZenFolders.createFolder([], {
label,
isLiveFolder: true,
});
if (icon) {
this.window.gZenFolders.setFolderUserIcon(folder, icon);
}
const config = {
state: this.#applyDefaultStateValues({
url,
type: providerType,
}),
};
let liveFolder = new ProviderClass({
state: config.state,
manager: this,
id: folder.id,
});
this.liveFolders.set(folder.id, liveFolder);
this.folderRefs.set(liveFolder, folder);
liveFolder.start();
this.saveState();
return folder.id;
}
deleteFolder(id, deleteFolder = true) {
const liveFolder = this.liveFolders.get(id);
if (!liveFolder) {
return false;
}
liveFolder.stop();
this.liveFolders.delete(id);
const prefix = `${id}:`;
// Remove the dismissed items associated with the folder from the set
this.dismissedItems = new Set(
Array.from(this.dismissedItems).filter((itemId) => !itemId.startsWith(prefix))
);
if (deleteFolder) {
const folder = this.getFolderForLiveFolder(liveFolder);
if (folder) {
folder.delete();
}
}
this.saveState();
return true;
}
// Live Folder Updates
// -------------------
onLiveFolderFetch(liveFolder, items) {
const folder = this.getFolderForLiveFolder(liveFolder);
if (!folder) {
return;
}
const errorId = typeof items === "string" ? items : null;
liveFolder.state.lastErrorId = errorId;
// Display on error on the folder, null reset the error status.
this.#applyLiveFolderError(liveFolder, errorId);
if (errorId) {
liveFolder.requestSave();
return;
}
// itemid -> id:itemid
const itemIds = new Set(items.map((item) => this.#makeCompositeId(liveFolder.id, item.id)));
const outdatedTabs = [];
const existingItemIds = new Set();
for (const tab of folder.tabs) {
const itemId = tab.getAttribute("zen-live-folder-item-id");
if (!itemId) {
continue;
}
if (!itemIds.has(itemId)) {
outdatedTabs.push(tab);
continue;
}
existingItemIds.add(itemId);
}
this.window.gBrowser.removeTabs(outdatedTabs);
// Remove the dismissed items that are no longer in the given list
for (const dismissedItemId of this.dismissedItems) {
if (dismissedItemId.startsWith(`${liveFolder.id}:`) && !itemIds.has(dismissedItemId)) {
this.dismissedItems.delete(dismissedItemId);
}
}
// Only add the items that are not already in the folder and was not dismissed by the user
const newItems = items
.filter((item) => {
const compositeId = this.#makeCompositeId(liveFolder.id, item.id);
return !existingItemIds.has(compositeId) && !this.dismissedItems.has(compositeId);
})
.map((item) => {
const tab = this.window.gBrowser.addTrustedTab(item.url, {
createLazyBrowser: true,
inBackground: true,
skipAnimation: true,
noInitialLabel: true,
lazyTabTitle: item.title,
});
// createLazyBrowser can't be pinned by default
this.window.gBrowser.pinTab(tab);
if (item.icon) {
this.window.gBrowser.setIcon(tab, item.icon);
if (tab.linkedBrowser) {
lazy.TabStateCache.update(tab.linkedBrowser.permanentKey, {
image: null,
});
}
}
tab.setAttribute("zen-live-folder-item-id", this.#makeCompositeId(liveFolder.id, item.id));
if (item.subtitle) {
tab.setAttribute("zen-show-sublabel", item.subtitle);
const tabLabel = tab.querySelector(".zen-tab-sublabel");
this.window.document.l10n.setArgs(tabLabel, {
tabSubtitle: item.subtitle,
});
}
return tab;
});
// Wait for tabs to (hopefully) be initialized on all windows
lazy.setTimeout(() => {
folder.addTabs(newItems);
this.saveState();
}, 0);
}
// Helpers
// -------
#applyDefaultStateValues(state) {
state.interval ||= DEFAULT_FETCH_INTERVAL;
state.lastFetched ||= 0;
state.options ||= {};
return state;
}
#applyLiveFolderError(liveFolder, errorId = null) {
const folder = this.getFolderForLiveFolder(liveFolder);
if (!folder?.isLiveFolder) {
return;
}
const btn = folder.resetButton;
if (!btn) {
return;
}
if (errorId) {
btn.setAttribute("data-l10n-id", errorId);
btn.setAttribute("live-folder-action", liveFolder.id);
} else {
btn.setAttribute("data-l10n-id", "zen-folders-unload-all-tooltip");
btn.removeAttribute("live-folder-action");
}
}
getFolderForLiveFolder(liveFolder) {
if (this.folderRefs.has(liveFolder)) {
return this.folderRefs.get(liveFolder);
}
if (!this.window) {
return null;
}
const folder = lazy.ZenWindowSync.getItemFromWindow(this.window, liveFolder.id);
if (folder?.isZenFolder) {
this.folderRefs.set(liveFolder, folder);
}
return folder;
}
#makeCompositeId(folderId, itemId) {
return `${folderId}:${itemId}`;
}
// Persistence
// -----------
get #storePath() {
const profilePath = this.window.PathUtils.profileDir;
return this.window.PathUtils.join(profilePath, this.#saveFilename);
}
async #readStateFromDisk() {
this.#file = new lazy.JSONFile({
path: this.#storePath,
compression: "lz4",
});
await this.#file.load();
return this.#file.data;
}
#writeStateToDisk(data, soon = true) {
this.#file.data = data;
if (soon) {
this.#file.saveSoon();
} else {
this.#file._save();
}
}
saveState(soon = true) {
if (!this.#isInitialized) {
return;
}
let data = [];
for (let [id, liveFolder] of this.liveFolders) {
const prefix = `${id}:`;
const dismissedItems = Array.from(this.dismissedItems).filter((itemId) =>
itemId.startsWith(prefix)
);
const folder = this.getFolderForLiveFolder(liveFolder);
if (!folder) {
// Assume browser is quitting.
return;
}
const tabsState = [];
for (const tab of folder.tabs) {
const itemId = tab.getAttribute("zen-live-folder-item-id");
if (!itemId) {
continue;
}
tabsState.push({
itemId,
label: tab.getAttribute("zen-show-sublabel"),
icon: tab.iconImage.src,
});
}
// For UI manager to restore tabs state
liveFolder.tabsState = tabsState;
data.push({
id,
type: liveFolder.constructor.type,
data: liveFolder.serialize(),
dismissedItems,
tabsState,
});
}
this.#writeStateToDisk(data, soon);
}
async #restoreState() {
let data = await this.#readStateFromDisk();
if (!Array.isArray(data)) {
return;
}
await this.window.gZenWorkspaces.promiseInitialized;
const folders = this.window.gZenWorkspaces.allTabGroups;
for (let entry of data) {
let ProviderClass = this.registry.get(entry.type);
if (!ProviderClass) {
continue;
}
const folder = folders.find((x) => x.id === entry.id);
if (!folder) {
// No point restore if the live folder can't find its folder
continue;
}
entry.data.state = this.#applyDefaultStateValues(entry.data.state);
let liveFolder = new ProviderClass({
id: entry.id,
state: entry.data.state,
manager: this,
});
this.liveFolders.set(entry.id, liveFolder);
this.folderRefs.set(liveFolder, folder);
liveFolder.tabsState = entry.tabsState;
liveFolder.state.lastErrorId = entry.data.state.lastErrorId;
if (entry.dismissedItems && Array.isArray(entry.dismissedItems)) {
entry.dismissedItems.forEach((id) => this.dismissedItems.add(id));
}
liveFolder.start();
}
}
}
export const ZenLiveFoldersManager = new nsZenLiveFoldersManager();

View File

@@ -0,0 +1,221 @@
// 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/.
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenLiveFoldersManager: "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs",
});
class nsZenLiveFoldersUI {
init() {
const popup = window.document
.getElementById("context_zenLiveFolderOptions")
.querySelector("menupopup");
popup.addEventListener("command", (event) => {
const option = event.target;
const folderId = option.getAttribute("option-folder");
if (folderId) {
const folder = lazy.ZenLiveFoldersManager.getFolder(folderId);
if (folder && typeof folder.onOptionTrigger === "function") {
folder.onOptionTrigger(option);
}
}
});
window.gZenWorkspaces.promiseInitialized.finally(() => {
const manager = lazy.ZenLiveFoldersManager;
for (const liveFolder of manager.liveFolders.values()) {
this.#restoreUIStateForLiveFolder(liveFolder);
}
});
}
#restoreUIStateForLiveFolder(liveFolder) {
const folder = window.gZenWorkspaces.allTabGroups.find((x) => x.id === liveFolder.id);
if (!folder) {
return;
}
const btn = folder.resetButton;
if (!btn) {
return;
}
for (const { itemId, label } of liveFolder.tabsState) {
const tab = folder.tabs.find((t) => t.getAttribute("zen-live-folder-item-id") === itemId);
if (tab && label) {
const tabLabel = tab.querySelector(".zen-tab-sublabel");
tab.setAttribute("zen-show-sublabel", label);
window.document.l10n.setArgs(tabLabel, {
tabSubtitle: label,
});
}
}
const errorId = liveFolder.state.lastErrorId;
if (errorId) {
btn.setAttribute("data-l10n-id", errorId);
btn.setAttribute("live-folder-action", liveFolder.id);
return;
}
btn.setAttribute("data-l10n-id", "zen-folders-unload-all-tooltip");
btn.removeAttribute("live-folder-action");
}
#applyMenuItemAttributes(menuItem, option, folderId) {
menuItem.setAttribute("data-l10n-id", option.l10nId);
if (option.checked !== undefined) {
menuItem.setAttribute("checked", option.checked);
menuItem.setAttribute("type", option.type ?? "checkbox");
}
if (option.l10nArgs) {
menuItem.setAttribute("data-l10n-args", JSON.stringify(option.l10nArgs));
}
menuItem.setAttribute("option-folder", folderId);
menuItem.setAttribute("option-key", option.key);
if (option.disabled) {
menuItem.setAttribute("disabled", "true");
}
if (option.hidden) {
menuItem.setAttribute("hidden", "true");
}
}
#appendOptions(parentPopup, options, folderId) {
for (const option of options) {
if (option.type === "separator") {
parentPopup.appendChild(document.createXULElement("menuseparator"));
continue;
}
if (option.options) {
const menu = document.createXULElement("menu");
this.#applyMenuItemAttributes(menu, option, folderId);
const subPopup = document.createXULElement("menupopup");
this.#appendOptions(subPopup, option.options, folderId);
menu.appendChild(subPopup);
parentPopup.appendChild(menu);
continue;
}
const menuItem = document.createXULElement("menuitem");
this.#applyMenuItemAttributes(menuItem, option, folderId);
if (option.value !== undefined) {
menuItem.setAttribute("option-value", option.value);
}
parentPopup.appendChild(menuItem);
}
}
buildContextMenu(folder) {
const optionsElement = document.getElementById("context_zenLiveFolderOptions");
let hidden = true;
if (folder.isLiveFolder) {
const popup = optionsElement.querySelector("menupopup");
const liveFolder = lazy.ZenLiveFoldersManager.getFolder(folder.id);
const MINUTE_MS = 60 * 1000;
const HOUR_MS = 60 * MINUTE_MS;
let intervals = [];
for (let mins = 15; mins <= 30; mins *= 2) {
intervals.push({ mins });
}
for (let hours = 1; hours <= 8; hours *= 2) {
intervals.push({ hours });
}
intervals = intervals.map((entry) => {
const ms = "mins" in entry ? entry.mins * MINUTE_MS : entry.hours * HOUR_MS;
return {
l10nId:
"mins" in entry
? "zen-live-folder-fetch-interval-mins"
: "zen-live-folder-fetch-interval-hours",
l10nArgs: entry,
type: "radio",
checked: liveFolder.state.interval === ms,
key: "setInterval",
value: ms,
};
});
const contextMenuItems = [
{
key: "lastFetched",
l10nId: liveFolder.state.lastErrorId || "zen-live-folder-last-fetched",
l10nArgs: { time: this.#timeAgo(liveFolder.state.lastFetched) },
disabled: true,
},
{
key: "setInterval",
l10nId: "zen-live-folder-option-fetch-interval",
options: intervals,
},
{
key: "refresh",
l10nId: "zen-live-folder-refresh",
},
{ type: "separator" },
...liveFolder.options,
];
popup.innerHTML = "";
this.#appendOptions(popup, contextMenuItems, folder.id);
hidden = false;
}
optionsElement.hidden = hidden;
document.getElementById("live-folder-separator").hidden = hidden;
}
#timeAgo(date) {
if (date === 0) {
return "-";
}
const rtf = new Intl.RelativeTimeFormat(Services.locale.appLocaleAsBCP47, { numeric: "auto" });
const secondsDiff = (date - Date.now()) / 1000;
const absSeconds = Math.abs(secondsDiff);
const ranges = {
day: 86400,
hour: 3600,
minute: 60,
second: 1,
};
if (Number.isFinite(secondsDiff)) {
for (const [key, value] of Object.entries(ranges)) {
if (absSeconds >= value) {
return rtf.format(Math.round(secondsDiff / value), key);
}
}
return rtf.format(Math.round(secondsDiff), "second");
}
return "-";
}
}
window.gZenLiveFoldersUI = new nsZenLiveFoldersUI();

View File

@@ -0,0 +1,5 @@
# 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/.
content/browser/zen-components/ZenLiveFoldersUI.mjs (../../zen/live-folders/ZenLiveFoldersUI.mjs)

View File

@@ -0,0 +1,10 @@
# 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/.
EXTRA_JS_MODULES.zen += [
"providers/GithubLiveFolder.sys.mjs",
"providers/RssLiveFolder.sys.mjs",
"ZenLiveFolder.sys.mjs",
"ZenLiveFoldersManager.sys.mjs",
]

View File

@@ -0,0 +1,260 @@
// 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 { nsZenLiveFolderProvider } from "resource:///modules/zen/ZenLiveFolder.sys.mjs";
export class nsGithubLiveFolderProvider extends nsZenLiveFolderProvider {
static type = "github";
constructor({ id, state, manager }) {
super({ id, state, manager });
this.state.url = "https://github.com/issues/assigned";
this.state.type = state.type;
this.state.options = state.options ?? {};
this.state.repos = new Set(state.repos ?? []);
this.state.options.repoExcludes = new Set(state.options.repoExcludes ?? []);
}
async fetchItems() {
try {
const hasAnyFilterEnabled =
(this.state.options.authorMe ?? false) ||
(this.state.options.assignedMe ?? true) ||
(this.state.options.reviewRequested ?? false);
if (!hasAnyFilterEnabled) {
return "zen-live-folder-github-no-filter";
}
const searchParams = this.#buildSearchOptions();
const url = `${this.state.url}?${searchParams}`;
const { text, status } = await this.fetch(url);
// Assume no auth
if (status === 404) {
return "zen-live-folder-github-no-auth";
}
const document = new DOMParser().parseFromString(text, "text/html");
const issues = document.querySelectorAll(
"div[class^=IssueItem-module__defaultRepoContainer]"
);
const items = [];
const activeRepos = new Set();
if (issues.length) {
const authors = document.querySelectorAll("a[class^=IssueItem-module__authorCreatedLink]");
const titles = document.querySelectorAll("div[class^=Title-module__container]");
const links = document.querySelectorAll('[data-testid="issue-pr-title-link"]');
for (let i = 0; i < issues.length; i++) {
const [rawRepo, rawNumber] = issues[i].childNodes;
const author = authors[i]?.textContent;
const title = titles[i]?.textContent;
const issueUrl = links[i]?.href;
const repo = rawRepo.textContent?.trim();
if (repo) {
activeRepos.add(repo);
}
const numberMatch = rawNumber?.textContent?.match(/[0-9]+/);
const number = numberMatch?.[0] ?? "";
items.push({
title,
subtitle: author,
icon: "chrome://browser/content/zen-images/favicons/github.svg",
url: `https://github.com/${issueUrl}`,
id: `${repo}#${number}`,
});
}
}
this.state.repos = activeRepos;
return items;
} catch {
return "zen-live-folder-failed-fetch";
}
}
#buildSearchOptions() {
let searchParams = new URLSearchParams();
const options = [
{
value: "state:open",
enabled: true,
},
{
value: "sort:updated-desc",
enabled: true,
},
[
{
value: "is:pr",
enabled: this.state.type === "pull-requests",
},
{
value: "is:issue",
enabled: this.state.type === "issues",
},
],
[
{
value: "author:@me",
enabled: this.state.options.authorMe ?? false,
},
{
value: "assignee:@me",
enabled: this.state.options.assignedMe ?? true,
},
{
value: "review-requested:@me",
enabled: this.state.options.reviewRequested ?? false,
},
],
];
const excluded = this.state.options.repoExcludes;
for (const repo of excluded) {
if (repo && repo.trim()) {
options.push({ value: `-repo:${repo.trim()}`, enabled: true });
}
}
let outputString = "";
for (const option of options) {
if (Array.isArray(option)) {
const enabledOptions = option.filter((x) => x.enabled).map((x) => x.value);
if (enabledOptions.length) {
outputString += ` (${enabledOptions.join(" OR ")}) `;
}
continue;
}
if (option.enabled) {
outputString += ` ${option.value} `;
}
}
searchParams.set("q", outputString);
return searchParams.toString();
}
get options() {
const excluded = this.state.options.repoExcludes;
const repoOptions = Array.from(this.state.repos.union(excluded))
.sort((a, b) => a.localeCompare(b))
.map((repo) => ({
l10nId: "zen-live-folder-github-option-repo",
l10nArgs: { repo },
key: "repoExclude",
value: repo,
type: "checkbox",
checked: !excluded.has(repo),
}));
if (repoOptions.length) {
repoOptions.push({ type: "separator" });
}
repoOptions.push({
l10nId: "zen-live-folder-github-option-repo-list-note",
disabled: true,
});
return [
{
l10nId: "zen-live-folder-github-option-author-self",
key: "authorMe",
checked: this.state.options.authorMe ?? false,
},
{
l10nId: "zen-live-folder-github-option-assigned-self",
key: "assignedMe",
checked: this.state.options.assignedMe ?? true,
},
{
l10nId: "zen-live-folder-github-option-review-requested",
key: "reviewRequested",
checked: this.state.options.reviewRequested ?? false,
hidden: this.state.type === "issues",
},
{ type: "separator" },
{
l10nId: "zen-live-folder-github-option-repo-filter",
key: "repoExclude",
options: repoOptions,
// 1 repo + separator + note = 3 options, so if we have less than 4 options it means we don't have any repo to exclude
disabled: repoOptions.length < 4,
},
];
}
onOptionTrigger(option) {
super.onOptionTrigger(option);
const key = option.getAttribute("option-key");
const checked = option.getAttribute("checked") === "true";
if (!this.options.some((x) => x.key === key)) {
return;
}
if (key === "repoExclude") {
const repo = option.getAttribute("option-value");
if (!repo) {
return;
}
const excluded = this.state.options.repoExcludes;
if (checked) {
excluded.delete(repo);
} else {
excluded.add(repo);
}
this.state.options.repoExcludes = excluded;
} else {
this.state.options[key] = checked;
}
this.refresh();
this.requestSave();
}
async onActionButtonClick(errorId) {
super.onActionButtonClick(errorId);
switch (errorId) {
case "zen-live-folder-github-no-auth": {
const tab = this.manager.window.gBrowser.addTrustedTab("https://github.com/login");
this.manager.window.gBrowser.selectedTab = tab;
break;
}
case "zen-live-folder-github-no-filter": {
this.refresh();
break;
}
}
}
serialize() {
return {
state: {
...this.state,
repos: Array.from(this.state.repos),
options: {
...this.state.options,
repoExcludes: Array.from(this.state.options.repoExcludes),
},
},
};
}
}

View File

@@ -0,0 +1,274 @@
// 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 { nsZenLiveFolderProvider } from "resource:///modules/zen/ZenLiveFolder.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
});
ChromeUtils.defineLazyGetter(
lazy,
"l10n",
() => new Localization(["browser/zen-live-folders.ftl"])
);
export class nsRssLiveFolderProvider extends nsZenLiveFolderProvider {
static type = "rss";
constructor({ id, state, manager }) {
super({ id, state, manager });
this.state.url = state.url;
this.state.maxItems = state.maxItems ?? 10;
this.state.timeRange = state.timeRange ?? 0;
}
async fetchItems() {
try {
const { text } = await this.fetch(this.state.url);
const doc = new DOMParser().parseFromString(text, "text/xml");
const cutoffTime = Date.now() - this.state.timeRange;
const isAtom = doc.querySelector("feed > entry") !== null;
const selector = isAtom ? "entry" : "item";
const elements = doc.querySelectorAll(selector);
const items = Array.from(elements)
.map((item) => {
const title = item.querySelector("title")?.textContent || "";
const linkNode = item.querySelector("link");
const url =
isAtom && linkNode ? linkNode.getAttribute("href") : linkNode?.textContent || "";
const guid = item.querySelector(isAtom ? "id" : "guid")?.textContent;
const id = guid || url;
const dateStr = item.querySelector(isAtom ? "updated" : "pubDate")?.textContent;
const date = dateStr ? new Date(dateStr) : null;
return { title, url, id, date };
})
.filter((item) => {
if (!item.url || !item.date) {
return false;
}
if (!this.state.timeRange) {
return true;
}
return !isNaN(item.date.getTime()) && item.date.getTime() >= cutoffTime;
})
.slice(0, this.state.maxItems);
for (let item of items) {
if (item.url) {
try {
const url = new URL(item.url);
const favicon = await lazy.PlacesUtils.favicons.getFaviconForPage(
Services.io.newURI(url.href)
);
item.icon =
favicon?.dataURI.spec ||
this.manager.window.gZenEmojiPicker.getSVGURL("logo-rss.svg");
} catch {
// Ignore errors related to fetching favicons for individual items
}
}
}
return items;
} catch {
return "zen-live-folder-failed-fetch";
}
}
_buildRadioOption({ key, value, l10nId, l10nArgs }) {
return {
type: "radio",
key,
value,
l10nId,
l10nArgs,
checked: this.state[key] === value,
};
}
_buildItemLimitOptions() {
const entries = [5, 10, 25, 50];
return entries.map((entry) => {
return this._buildRadioOption({
key: "maxItems",
value: entry,
l10nId: "zen-live-folder-rss-option-item-limit-num",
l10nArgs: { limit: entry },
});
});
}
_buildTimeRangeOptions() {
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
const entries = [
{ hours: 1, ms: 1 * HOUR_MS },
{ hours: 6, ms: 6 * HOUR_MS },
{ hours: 12, ms: 12 * HOUR_MS },
{ hours: 24, ms: 24 * HOUR_MS },
{ days: 3, ms: 3 * DAY_MS },
];
return [
this._buildRadioOption({
key: "timeRange",
value: 0,
l10nId: "zen-live-folder-time-range-all-time",
}),
{ type: "separator" },
...entries.map((entry) => {
const isDays = "days" in entry;
return this._buildRadioOption({
key: "timeRange",
value: entry.ms,
l10nId: isDays ? "zen-live-folder-time-range-days" : "zen-live-folder-time-range-hours",
l10nArgs: isDays ? { days: entry.days } : { hours: entry.hours },
});
}),
];
}
get options() {
return [
{
l10nId: "zen-live-folder-rss-option-feed-url",
key: "feedURL",
},
{
l10nId: "zen-live-folder-rss-option-item-limit",
key: "maxItems",
options: this._buildItemLimitOptions(),
},
{
l10nId: "zen-live-folder-rss-option-time-range",
key: "timeRange",
options: this._buildTimeRangeOptions(),
},
];
}
// static so it can be easily accessed by the manager without having to create the live folder first
static async getMetadata(url, window) {
try {
const response = await fetch(url);
if (!response.ok) {
return { label: "", icon: window.gZenEmojiPicker.getSVGURL("logo-rss.svg") };
}
const text = await response.text();
const doc = new DOMParser().parseFromString(text, "text/xml");
const isAtom = doc.querySelector("feed") !== null;
const title = (
isAtom
? doc.querySelector("feed > title")?.textContent
: doc.querySelector("rss > channel > title, channel > title")?.textContent
)?.trim();
const linkNode = isAtom
? doc.querySelector("feed > link[rel='alternate'][href], feed > link[href]")
: doc.querySelector("rss > channel > link, channel > link");
const feedLink =
(isAtom ? linkNode?.getAttribute("href") : linkNode?.textContent)?.trim() || "";
const faviconPageUrl = feedLink ? new URL(feedLink, url).href : url;
let favicon = await lazy.PlacesUtils.favicons.getFaviconForPage(
Services.io.newURI(faviconPageUrl)
);
return {
label: title || "",
icon: favicon?.dataURI.spec || window.gZenEmojiPicker.getSVGURL("logo-rss.svg"),
};
} catch (e) {
return { label: "", icon: window.gZenEmojiPicker.getSVGURL("logo-rss.svg") };
}
}
static async promptForFeedUrl(window, initialUrl = "") {
const input = { value: initialUrl };
const [prompt] = await lazy.l10n.formatValues(["zen-live-folder-rss-prompt-feed-url"]);
const promptOk = Services.prompt.prompt(window, prompt, null, input, null, {
value: null,
});
if (!promptOk) {
return null;
}
try {
const raw = (input.value ?? "").trim();
const parsed = new URL(raw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error();
}
return parsed.href;
} catch {
window.gZenUIManager.showToast("zen-live-folder-rss-invalid-url-title", {
descriptionId: "zen-live-folder-rss-invalid-url-description",
timeout: 6000,
});
}
return null;
}
async getMetadata() {
return nsRssLiveFolderProvider.getMetadata(this.state.url, this.manager.window);
}
async onOptionTrigger(option) {
super.onOptionTrigger(option);
const key = option.getAttribute("option-key");
const value = option.getAttribute("option-value");
if (!this.options.some((x) => x.key === key)) {
return;
}
switch (key) {
case "feedURL": {
const url = await nsRssLiveFolderProvider.promptForFeedUrl(
this.manager.window,
this.state.url
);
if (url) {
this.state.url = url;
this.refresh();
}
break;
}
case "maxItems":
case "timeRange": {
const parsedValue = Number.parseInt(value);
if (!Number.isNaN(parsedValue)) {
this.state[key] = parsedValue;
this.refresh();
}
break;
}
}
this.requestSave();
}
serialize() {
return {
state: this.state,
};
}
}

View File

@@ -10,6 +10,7 @@ DIRS += [
"common",
"drag-and-drop",
"glance",
"live-folders",
"mods",
"tests",
"urlbar",

View File

@@ -8,6 +8,7 @@ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenLiveFoldersManager: "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs",
@@ -521,6 +522,7 @@ export class nsZenSessionManager {
} else {
this.#file._save();
}
lazy.ZenLiveFoldersManager.saveState(soon);
this.#debounceRegeneration();
this.log(`Saving Zen session data with ${sidebar.tabs?.length || 0} tabs`);
}

View File

@@ -42,6 +42,7 @@ const EVENTS = [
"TabAddedToEssentials",
"TabRemovedFromEssentials",
"TabUngrouped",
"TabGroupUpdate",
"TabGroupCreate",
"TabGroupRemoved",
@@ -146,7 +147,7 @@ class nsZenWindowSync {
/**
* @returns {Window|null} The first opened browser window, or null if none exist.
*/
get #firstSyncedWindow() {
get firstSyncedWindow() {
for (let window of this.#browserWindows) {
return window;
}
@@ -475,6 +476,27 @@ class nsZenWindowSync {
this.#maybeSyncAttributeChange(aOriginalItem, aTargetItem, "zen-workspace-id");
this.#syncItemPosition(aOriginalItem, aTargetItem, aWindow);
}
if (aOriginalItem.hasAttribute("zen-live-folder-item-id")) {
this.#maybeSyncAttributeChange(aOriginalItem, aTargetItem, "zen-live-folder-item-id");
this.#maybeSyncAttributeChange(aOriginalItem, aTargetItem, "zen-show-sublabel");
this.#syncTabSubtitle(aWindow, aOriginalItem, aTargetItem);
} else if (aTargetItem.hasAttribute("zen-live-folder-item-id")) {
aTargetItem.removeAttribute("zen-live-folder-item-id");
if (aTargetItem.hasAttribute("zen-show-sublabel")) {
this.#syncTabSubtitle(aWindow, aOriginalItem, aTargetItem);
aTargetItem.removeAttribute("zen-show-sublabel");
}
}
}
#syncTabSubtitle(aWindow, aOriginalItem, aTargetItem) {
const subLabel = aOriginalItem.getAttribute("zen-show-sublabel");
const targetLabel = aTargetItem.querySelector(".zen-tab-sublabel");
if (targetLabel) {
aWindow.document.l10n.setArgs(targetLabel, {
tabSubtitle: subLabel || "zen-default-pinned",
});
}
}
/**
@@ -1041,7 +1063,7 @@ class nsZenWindowSync {
(tab) => !tab.hasAttribute("zen-empty-tab")
);
const selectedTab = aWindow.gBrowser.selectedTab;
let win = this.#firstSyncedWindow;
let win = this.firstSyncedWindow;
const moveAllTabsToWindow = async (allowSelected = false) => {
const { gBrowser, gZenWorkspaces } = win;
win.focus();
@@ -1369,6 +1391,14 @@ class nsZenWindowSync {
return this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_ICON | SYNC_FLAG_LABEL);
}
on_TabUngrouped() {
// No need to sync anything when a tab is ungrouped, since on_TabMove will take
// care of moving the tab to the correct position. We still need to listen to this
// in order to throw sync events for other components such as live folders to
// update their state, but we don't need to do anything here.
return Promise.resolve();
}
on_ZenTabRemovedFromSplit(aEvent) {
const tab = aEvent.target;
const window = tab.ownerGlobal;

View File

@@ -758,6 +758,15 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature {
pinHasChangedUrl(tab) {
if (tab.hasAttribute("zen-pinned-changed")) {
const showSublabel = tab.hasAttribute("zen-show-sublabel");
if (showSublabel) {
tab.removeAttribute("zen-show-sublabel");
const label = tab.querySelector(".zen-tab-sublabel");
window.document.l10n.setArgs(label, {
tabSubtitle: "zen-default-pinned",
});
}
return;
}
if (tab.group?.hasAttribute("split-view-group")) {

View File

@@ -472,7 +472,7 @@
display: none;
}
.tab-reset-pin-label {
.zen-tab-sublabel {
pointer-events: none;
transition:
opacity 0.1s ease-in-out,
@@ -480,11 +480,18 @@
max-height 0.1s ease-in-out;
max-height: 0px;
opacity: 0;
transform: translateY(-5px);
transform: translateY(-4px);
font-size: x-small;
margin: 0;
}
.tabbrowser-tab[zen-show-sublabel] {
& .tab-label-container .zen-tab-sublabel {
max-height: 10px;
opacity: 0.5;
}
}
#navigator-toolbox[zen-sidebar-expanded="true"] {
--zen-toolbox-min-width: fit-content;
@@ -637,7 +644,7 @@
}
&[zen-pinned-changed="true"]:not([zen-essential]) .tab-reset-pin-button:hover {
& ~ .tab-label-container .tab-reset-pin-label {
& ~ .tab-label-container .zen-tab-sublabel {
max-height: 10px;
opacity: 0.6;
}
@@ -877,11 +884,11 @@
#tabbrowser-tabs {
& .tabbrowser-tab {
&[pinned]:not([pending="true"]) .tab-close-button {
&[pinned]:not([pending="true"]:not([folder-active="true"])) .tab-close-button {
display: none !important;
}
&[pinned]:not([pending="true"]):not([zen-essential]) {
&[pinned]:is(:not([pending="true"]), [folder-active="true"]):not([zen-essential]) {
&:hover .tab-reset-button,
&[visuallyselected] .tab-reset-button {
display: block;

View File

@@ -0,0 +1,7 @@
# 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/.
["browser_live_folder.js"]
["browser_rss_live_folder.js"]
["browser_github_live_folder.js"]

View File

@@ -0,0 +1,165 @@
/* Any copyright is dedicated to the Public Domain.
https://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
ChromeUtils.defineESModuleGetters(this, {
sinon: "resource://testing-common/Sinon.sys.mjs",
nsGithubLiveFolderProvider: "resource:///modules/zen/GithubLiveFolder.sys.mjs",
});
function getGithubProviderForTest(sandbox, customOptions = {}) {
const defaultOptions = {
authorMe: true,
assignedMe: false,
reviewRequested: false,
...customOptions,
};
const mockManager = {
saveState: sandbox.spy(),
};
const initialState = {
interval: 60,
maxItems: 10,
lastFetched: 0,
type: customOptions.type,
options: defaultOptions,
};
let instance = new nsGithubLiveFolderProvider({
id: "test-github-folder",
state: initialState,
manager: mockManager,
});
sandbox.stub(instance, "fetch");
return instance;
}
add_task(async function test_fetch_items_url_construction() {
info("should construct the correct GitHub search URL based on default options");
let sandbox = sinon.createSandbox();
let instance = getGithubProviderForTest(sandbox, {
authorMe: true,
assignedMe: false,
reviewRequested: false,
type: "pull-requests",
});
instance.fetch.resolves({
status: 200,
text: "<html></html>",
});
await instance.fetchItems();
Assert.ok(instance.fetch.calledOnce, "Fetch should be called once");
const fetchedUrl = new URL(instance.fetch.firstCall.args[0]);
const searchParams = fetchedUrl.searchParams;
Assert.ok(fetchedUrl.href.startsWith("https://github.com/issues/assigned"));
const query = searchParams.get("q");
Assert.ok(query.includes("state:open"), "Should include state:open");
Assert.ok(query.includes("is:pr"), "Should include is:PR");
Assert.ok(query.includes("author:@me"), "Should include author:@me");
Assert.ok(!query.includes("assignee:@me"), "Should NOT include assignee:@me");
Assert.ok(!query.includes("review-requested:@me"), "Should NOT include review-requested");
sandbox.restore();
});
add_task(async function test_fetch_items_url_complex_options() {
info("should construct query with multiple enabled options");
let sandbox = sinon.createSandbox();
let instance = getGithubProviderForTest(sandbox, {
authorMe: true,
assignedMe: true,
reviewRequested: true,
});
instance.fetch.resolves({
status: 200,
text: "<html></html>",
});
await instance.fetchItems();
const fetchedUrl = new URL(instance.fetch.firstCall.args[0]);
const query = fetchedUrl.searchParams.get("q");
Assert.ok(query.includes("author:@me"), "Should include author");
Assert.ok(query.includes("assignee:@me"), "Should include assignee");
Assert.ok(query.includes("review-requested:@me"), "Should include review-requested");
Assert.ok(query.includes(" OR "), "Should contain OR operators");
sandbox.restore();
});
add_task(async function test_html_parsing_logic() {
info("should parse HTML and return structured items");
let sandbox = sinon.createSandbox();
let instance = getGithubProviderForTest(sandbox);
const mockHtml = `
<html>
<body>
<div>
<div class="IssueItem-module__defaultRepoContainer"><span>mozilla/zen</span><span>#101</span></div>
<a class="IssueItem-module__authorCreatedLink">UserA</a>
<div class="Title-module__container">Fix the login bug</div>
<a data-testid="issue-pr-title-link" href="issues/101"></a>
</div>
<div>
<div class="IssueItem-module__defaultRepoContainer"><span>mozilla/zen</span><span>#102</span></div>
<a class="IssueItem-module__authorCreatedLink">UserB</a>
<div class="Title-module__container">Add dark mode</div>
<a data-testid="issue-pr-title-link" href="pull/102"></a>
</div>
</body>
</html>
`;
instance.fetch.resolves({
text: mockHtml,
status: 200,
});
const items = await instance.fetchItems();
Assert.equal(items.length, 2, "Should find 2 items");
Assert.equal(items[0].title, "Fix the login bug");
Assert.equal(items[0].subtitle, "UserA");
Assert.equal(items[0].id, "mozilla/zen#101");
Assert.equal(items[0].url, "https://github.com/issues/101");
Assert.equal(items[1].title, "Add dark mode");
Assert.equal(items[1].subtitle, "UserB");
Assert.equal(items[1].id, "mozilla/zen#102");
Assert.equal(items[1].url, "https://github.com/pull/102");
sandbox.restore();
});
add_task(async function test_fetch_network_error() {
info("should gracefully handle network exceptions");
let sandbox = sinon.createSandbox();
let instance = getGithubProviderForTest(sandbox);
instance.fetch.rejects(new Error("Network down"));
const errorId = await instance.fetchItems();
Assert.equal(errorId, "zen-live-folder-failed-fetch", "Should return an error on failed fetch");
sandbox.restore();
});

View File

@@ -0,0 +1,83 @@
/* Any copyright is dedicated to the Public Domain.
https://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
ChromeUtils.defineESModuleGetters(this, {
sinon: "resource://testing-common/Sinon.sys.mjs",
nsZenLiveFolderProvider: "resource:///modules/zen/ZenLiveFolder.sys.mjs",
});
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe("Zen Live Folder Scheduling", () => {
let instance;
let sandbox;
let mockManager;
beforeEach(() => {
sandbox = sinon.createSandbox();
mockManager = {
saveState: sandbox.spy(),
onLiveFolderFetch: sandbox.spy(),
};
});
afterEach(() => {
if (instance) {
instance.stop();
}
sandbox.restore();
});
it("should fetch correctly at an interval", async () => {
const INTERVAL = 250;
instance = new nsZenLiveFolderProvider({
id: "test-folder",
manager: mockManager,
state: {
interval: INTERVAL,
lastFetched: Date.now(),
},
});
const fetchStub = sandbox.stub(instance, "fetchItems").resolves(["item1"]);
sandbox.stub(instance, "getMetadata").returns({});
instance.start();
sinon.assert.notCalled(fetchStub);
await sleep(INTERVAL / 2);
sinon.assert.notCalled(fetchStub);
await sleep(INTERVAL * 2);
Assert.ok(fetchStub.callCount > 1, "Should have fetched more than once");
sinon.assert.called(mockManager.saveState);
sinon.assert.called(mockManager.onLiveFolderFetch);
});
it("should fetch immediately if overdue", async () => {
const INTERVAL = 500;
instance = new nsZenLiveFolderProvider({
id: "test-folder-overdue",
manager: mockManager,
state: {
interval: INTERVAL,
lastFetched: Date.now() - 3600000,
},
});
const fetchStub = sandbox.stub(instance, "fetchItems").resolves(["item1"]);
sandbox.stub(instance, "getMetadata").returns({});
instance.start();
await sleep(20);
sinon.assert.calledOnce(fetchStub);
});
});

View File

@@ -0,0 +1,229 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
ChromeUtils.defineESModuleGetters(this, {
sinon: "resource://testing-common/Sinon.sys.mjs",
nsRssLiveFolderProvider: "resource:///modules/zen/RssLiveFolder.sys.mjs",
});
function getRssProviderForTest(sandbox, customState = {}) {
const defaultState = {
url: "https://example.com/feed.xml",
interval: 60,
maxItems: 10,
timeRange: 24 * 60 * 60 * 1000, // 24 Hours
lastFetched: 0,
...customState,
};
const mockManager = {
saveState: sandbox.spy(),
};
const instance = new nsRssLiveFolderProvider({
id: "test-rss",
state: defaultState,
manager: mockManager,
});
sandbox.stub(instance, "fetch");
sandbox.stub(instance, "getMetadata").resolves({});
return instance;
}
add_task(async function test_rss_parsing() {
info("should parse standard RSS 2.0 feeds correctly");
let sandbox = sinon.createSandbox();
let instance = getRssProviderForTest(sandbox);
const rssXml = `
<rss version="2.0">
<channel>
<title>Tech News</title>
<item>
<title>Mozilla Releases Zen</title>
<link>https://mozilla.org/zen</link>
<guid>guid-123</guid>
<pubDate>${new Date().toUTCString()}</pubDate>
</item>
<item>
<title>Another Article</title>
<link>https://example.com/article</link>
<pubDate>${new Date().toUTCString()}</pubDate>
</item>
</channel>
</rss>
`;
instance.fetch.resolves({
text: rssXml,
});
const items = await instance.fetchItems();
Assert.equal(items.length, 2, "Should find 2 items");
// Check mapping
Assert.equal(items[0].title, "Mozilla Releases Zen");
Assert.equal(items[0].url, "https://mozilla.org/zen");
Assert.equal(items[0].id, "guid-123");
// Check fallback for ID
// In the second item, no guid is present, but <link> is.
Assert.equal(items[1].title, "Another Article");
Assert.equal(items[1].id, "https://example.com/article");
sandbox.restore();
});
add_task(async function test_atom_parsing() {
info("should parse Atom feeds correctly");
let sandbox = sinon.createSandbox();
let instance = getRssProviderForTest(sandbox);
const atomXml = `
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom Feed</title>
<entry>
<title>Atom Entry 1</title>
<link href="https://example.com/atom1" />
<id>urn:uuid:12345</id>
<updated>${new Date().toISOString()}</updated>
</entry>
</feed>
`;
instance.fetch.resolves({
text: atomXml,
});
const items = await instance.fetchItems();
Assert.equal(items.length, 1);
Assert.equal(items[0].title, "Atom Entry 1");
Assert.equal(items[0].url, "https://example.com/atom1");
Assert.equal(items[0].id, "urn:uuid:12345");
sandbox.restore();
});
add_task(async function test_time_range_filtering() {
info("should filter out items older than timeRange");
let sandbox = sinon.createSandbox();
const ONE_HOUR = 60 * 60 * 1000;
let instance = getRssProviderForTest(sandbox, { timeRange: ONE_HOUR });
const now = Date.now();
const recentDate = new Date(now - 10 * 60 * 1000).toUTCString(); // 10 mins ago
const oldDate = new Date(now - ONE_HOUR * 2).toUTCString(); // 2 hours ago
const rssXml = `
<rss version="2.0">
<channel>
<item>
<title>Recent News</title>
<pubDate>${recentDate}</pubDate>
<link>http://a.com</link>
</item>
<item>
<title>Old News</title>
<pubDate>${oldDate}</pubDate>
<link>http://b.com</link>
</item>
</channel>
</rss>
`;
instance.fetch.resolves({
text: rssXml,
});
const items = await instance.fetchItems();
Assert.equal(items.length, 1, "Should only return 1 item");
Assert.equal(items[0].title, "Recent News", "Should keep the recent item");
sandbox.restore();
});
add_task(async function test_max_items_limit() {
info("should respect maxItems limit");
let sandbox = sinon.createSandbox();
let instance = getRssProviderForTest(sandbox, { maxItems: 2 });
const date = new Date().toUTCString();
const rssXml = `
<rss version="2.0">
<channel>
<item><title>1</title><link>1</link><pubDate>${date}</pubDate></item>
<item><title>2</title><link>2</link><pubDate>${date}</pubDate></item>
<item><title>3</title><link>3</link><pubDate>${date}</pubDate></item>
</channel>
</rss>
`;
instance.fetch.resolves({
text: rssXml,
});
const items = await instance.fetchItems();
Assert.equal(items.length, 2, "Should be capped at 2 items");
Assert.equal(items[0].title, "1");
Assert.equal(items[1].title, "2");
sandbox.restore();
});
add_task(async function test_invalid_dates() {
info("should ignore items with invalid dates");
let sandbox = sinon.createSandbox();
let instance = getRssProviderForTest(sandbox);
const rssXml = `
<rss version="2.0">
<channel>
<item>
<title>Bad Date</title>
<link>http://bad.com</link>
<pubDate>ThisIsNotADate</pubDate>
</item>
<item>
<title>No Date</title>
<link>http://nodate.com</link>
</item>
</channel>
</rss>
`;
instance.fetch.resolves({
text: rssXml,
});
const items = await instance.fetchItems();
Assert.equal(items.length, 0, "Items with invalid/missing dates should be filtered");
sandbox.restore();
});
add_task(async function test_fetch_network_error() {
info("should return empty array on network error");
let sandbox = sinon.createSandbox();
let instance = getRssProviderForTest(sandbox);
instance.fetch.rejects(new Error("Network down"));
const items = await instance.fetchItems();
Assert.equal(items, "zen-live-folder-failed-fetch", "Should return an error on failed fetch");
sandbox.restore();
});

View File

@@ -7,6 +7,7 @@ BROWSER_CHROME_MANIFESTS += [
"container_essentials/browser.toml",
"folders/browser.toml",
"glance/browser.toml",
"live-folders/browser.toml",
"pinned/browser.toml",
"split_view/browser.toml",
"tabs/browser.toml",

View File

@@ -34,6 +34,7 @@ export default [
"gZenFolders",
"gZenMediaController",
"gZenGlanceManager",
"gZenLiveFoldersUI",
"gZenThemePicker",