no-bug: Add space button

This commit is contained in:
mr. m
2026-05-01 19:11:12 +02:00
parent 27f40393d5
commit 685cddf7c2
10 changed files with 459 additions and 95 deletions

View File

@@ -151,3 +151,5 @@ zen-window-sync-migration-dialog-accept = Got It
zen-appmenu-new-blank-window =
.label = New blank window
zen-spaces-search-placeholder =
.placeholder = Search your spaces...

View File

@@ -0,0 +1,23 @@
# 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/.
<panel id="zen-spaces-popup"
nonnativepopover="true"
type="arrow"
orient="vertical"
side="bottom"
hidden="true"
consumeoutsideclicks="never">
<hbox class="zen-spaces-list-header" flex="1">
<image class="zen-spaces-list-search-icon" src="chrome://global/skin/icons/search-glass.svg"/>
<html:input id="zen-spaces-list-search"
data-l10n-id="zen-spaces-search-placeholder"
type="search" />
</hbox>
<scrollbox class="zen-spaces-list-scrollbox" flex="1">
<vbox id="zen-spaces-list"></vbox>
<hbox id="zen-spaces-search-no-results" hidden="true" flex="1"
data-l10n-id="zen-spaces-search-no-results" />
</scrollbox>
</panel>

View File

@@ -5,6 +5,7 @@
#include zen-panels/theme-picker.inc
#include zen-panels/emojis-picker.inc
#include zen-panels/folders-search.inc
#include zen-panels/spaces-search.inc
#include zen-panels/site-data.inc
#include zen-panels/popups.inc

View File

@@ -5,6 +5,7 @@
EXTRA_JS_MODULES += [
"sys/ZenActorsManager.sys.mjs",
"sys/ZenCustomizableUI.sys.mjs",
"sys/ZenSearchPopup.sys.mjs",
"sys/ZenUIMigration.sys.mjs",
]

View File

@@ -0,0 +1,152 @@
/* 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/. */
/*
* Generic searchable XUL <panel> driver. One instance owns one panel +
* its search input + list + (optional) no-results element, and exposes
* `populate(items)` and `open(anchor, options)`.
*
* Each item passed to `populate` is `{ label, render?, onPick }`:
* - label: string used for the data-label search filter.
* - render: optional () => Element factory. If omitted a bare hbox
* with a <label> is created.
* - onPick: callback invoked when the item is clicked or activated
* via Enter on the keyboard.
*
* The driver handles:
* - filtering by lowercased substring match against data-label;
* - arrow-key / Tab navigation with [selected="true"] highlight;
* - Enter to activate the highlighted item;
* - autofocus of the search input on popupshown;
* - cleanup of all listeners on popuphidden.
*/
export class ZenSearchPopup {
#panel = null;
#searchInput = null;
#list = null;
#noResults = null;
#itemSelector = ".zen-search-popup-item";
#items = [];
/**
* @param {object} aOptions
* @param {Element} aOptions.panel The <panel> XUL element.
* @param {Element} aOptions.searchInput The search <html:input>.
* @param {Element} aOptions.list The container holding items.
* @param {Element} [aOptions.noResults] Optional "no results" element.
* @param {string} [aOptions.itemSelector] Per-item selector. Default
* is `.zen-search-popup-item`; custom items must carry that class
* or override this option.
*/
constructor({ panel, searchInput, list, noResults, itemSelector }) {
this.#panel = panel;
this.#searchInput = searchInput;
this.#list = list;
this.#noResults = noResults;
if (itemSelector) this.#itemSelector = itemSelector;
}
populate(items) {
this.#items = items;
this.#list.innerHTML = "";
const doc = this.#panel.ownerDocument;
for (const item of items) {
let node;
if (typeof item.render === "function") {
node = item.render();
} else {
node = doc.createXULElement("hbox");
const label = doc.createXULElement("label");
label.setAttribute("value", item.label);
node.appendChild(label);
}
node.classList.add(this.#itemSelector.replace(/^\./, ""));
node.setAttribute("data-label", item.label);
node.addEventListener("click", () => {
this.#panel.hidePopup();
item.onPick?.(item);
});
this.#list.appendChild(node);
}
}
open(anchor, { position = "after_end", onShown, onHidden } = {}) {
if (!this.#panel || !this.#list) return;
this.#panel.hidden = false;
if (this.#searchInput) this.#searchInput.value = "";
if (this.#noResults) this.#noResults.hidden = true;
const doc = this.#panel.ownerDocument;
const sel = this.#itemSelector;
const onSearch = () => {
const query = (this.#searchInput?.value || "").toLowerCase();
let visible = 0;
for (const item of this.#list.querySelectorAll(sel)) {
const label = item.getAttribute("data-label")?.toLowerCase() || "";
const found = label.includes(query);
item.hidden = !found;
if (found) visible++;
}
if (this.#noResults) this.#noResults.hidden = visible > 0;
};
if (this.#searchInput) {
this.#searchInput.addEventListener("input", onSearch);
}
const onKeyDown = event => {
if (
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Tab"
) {
event.preventDefault();
const isUp =
event.key === "ArrowUp" || (event.key === "Tab" && event.shiftKey);
const items = Array.from(this.#list.querySelectorAll(sel)).filter(
it => !it.hidden
);
if (!items.length) return;
let index = items.indexOf(
this.#list.querySelector(`${sel}[selected="true"]`)
);
index = isUp
? (index - 1 + items.length) % items.length
: (index + 1) % items.length;
items.forEach(it => it.removeAttribute("selected"));
const target = items[index];
target.setAttribute("selected", "true");
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
} else if (event.key === "Enter") {
const sel2 = this.#list.querySelector(`${sel}[selected="true"]`);
if (sel2) sel2.click();
}
};
doc.addEventListener("keydown", onKeyDown);
const onPanelShown = event => {
if (event.target !== this.#panel) return;
this.#searchInput?.focus();
this.#searchInput?.select?.();
onShown?.();
};
this.#panel.addEventListener("popupshown", onPanelShown);
const onPanelHidden = event => {
if (event.target !== this.#panel) return;
if (this.#searchInput) {
this.#searchInput.removeEventListener("input", onSearch);
}
doc.removeEventListener("keydown", onKeyDown);
this.#panel.removeEventListener("popupshown", onPanelShown);
this.#panel.removeEventListener("popuphidden", onPanelHidden);
onHidden?.();
};
this.#panel.addEventListener("popuphidden", onPanelHidden);
this.#panel.openPopup(anchor, position);
}
}

View File

@@ -3,6 +3,7 @@
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
import { nsZenDOMOperatedFeature } from "chrome://browser/content/zen-components/ZenCommonUtils.mjs";
import { ZenSearchPopup } from "resource:///modules/ZenSearchPopup.sys.mjs";
function formatRelativeTime(timestamp) {
const now = Date.now();
@@ -42,6 +43,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
);
#popup = null;
#searchPopup = null;
#popupTimer = null;
#mouseTimer = null;
#lastHighlightedGroup = null;
@@ -188,15 +190,12 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
#initTabsPopup() {
this.#popup = document.getElementById("zen-folder-tabs-popup");
const search = this.#popup.querySelector("#zen-folder-tabs-list-search");
const tabsList = this.#popup.querySelector("#zen-folder-tabs-list");
search.addEventListener("input", () => {
const query = search.value.toLowerCase();
for (const item of tabsList.children) {
item.hidden = !item.getAttribute("data-label").includes(query);
}
this.#searchPopup = new ZenSearchPopup({
panel: this.#popup,
searchInput: this.#popup.querySelector("#zen-folder-tabs-list-search"),
list: this.#popup.querySelector("#zen-folder-tabs-list"),
noResults: document.getElementById("zen-folder-tabs-search-no-results"),
itemSelector: ".folders-tabs-list-item",
});
this.#popup.addEventListener("mouseover", () => {
@@ -788,93 +787,18 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
document.getElementById("zen-folder-tabs-search-no-results").hidden = true;
this.#populateTabsList(activeGroup);
const search = this.#popup.querySelector("#zen-folder-tabs-list-search");
document.l10n.setArgs(search, {
"folder-name": activeGroup.name,
});
const tabsList = this.#popup.querySelector("#zen-folder-tabs-list");
const onSearchInput = () => {
const query = search.value.toLowerCase();
let foundTabs = 0;
for (const item of tabsList.children) {
const found = item.getAttribute("data-label").includes(query);
item.hidden = !found;
if (found) {
foundTabs++;
}
}
document.getElementById("zen-folder-tabs-search-no-results").hidden =
foundTabs > 0;
};
search.addEventListener("input", onSearchInput);
const onKeyDown = event => {
// Arrow down and up to navigate through the list
if (
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Tab"
) {
event.preventDefault();
let isUp =
event.key === "ArrowUp" || (event.key === "Tab" && event.shiftKey);
const items = Array.from(tabsList.children).filter(
item => !item.hidden
);
if (items.length === 0) {
return;
}
let index = items.indexOf(
tabsList.querySelector(".folders-tabs-list-item[selected]")
);
if (!isUp) {
index = (index + 1) % items.length;
} else {
index = (index - 1 + items.length) % items.length;
}
items.forEach(item => item.removeAttribute("selected"));
const targetItem = items[index];
targetItem.setAttribute("selected", "true");
targetItem.scrollIntoView({ block: "start", behavior: "smooth" });
} else if (event.key === "Enter") {
// Enter to select the currently highlighted item
const highlightedItem = tabsList.querySelector(
".folders-tabs-list-item[selected]"
);
if (highlightedItem) {
highlightedItem.click();
}
}
};
document.addEventListener("keydown", onKeyDown);
document.l10n.setArgs(
this.#popup.querySelector("#zen-folder-tabs-list-search"),
{ "folder-name": activeGroup.name }
);
const target = event.target;
target.setAttribute("open", true);
const handlePopupHidden = event => {
if (event.target !== this.#popup) {
return;
}
search.value = "";
target.removeAttribute("open");
search.removeEventListener("input", onSearchInput);
document.removeEventListener("keydown", onKeyDown);
};
this.#popup.addEventListener(
"popupshown",
() => {
search.focus();
search.select();
},
{ once: true }
);
this.#popup.addEventListener("popuphidden", handlePopupHidden, {
once: true,
this.#searchPopup.open(target, {
position: this.#searchPopupOptions,
onHidden: () => target.removeAttribute("open"),
});
this.#popup.openPopup(target, this.#searchPopupOptions);
}
get #searchPopupOptions() {

View File

@@ -4,6 +4,7 @@
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { ZenSpacesSearch } from "resource:///modules/zen/ZenSpacesSearch.sys.mjs";
const lazy = {};
@@ -77,6 +78,7 @@ class nsZenLittleWindow {
if (!this.#isLittleWindow(win)) {
return;
}
ZenSpacesSearch.init(win);
const observer = new win.ResizeObserver(entries => {
if (win.closed) {
return;

View File

@@ -0,0 +1,142 @@
/* 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 { ZenSearchPopup } from "resource:///modules/ZenSearchPopup.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs",
});
/*
* Owns the "send to a synced window" split-button that lives in the
* nav-bar of a little window. Main click opens the urlbar's current
* value in a fresh synced browser window using the active workspace;
* the dropdown opens a ZenSearchPopup over #zen-spaces-popup so the
* user can pick a different workspace to land in.
*/
class ZenSpacesSearchService {
/**
* Per-window setup.
* @param {Window} aWindow A little window.
*/
init(aWindow) {
if (!aWindow || aWindow._zenSpacesSearchInited) return;
aWindow._zenSpacesSearchInited = true;
const doc = aWindow.document;
const panel = doc.getElementById("zen-spaces-popup");
if (!panel) return;
const popup = new ZenSearchPopup({
panel,
searchInput: doc.getElementById("zen-spaces-list-search"),
list: doc.getElementById("zen-spaces-list"),
noResults: doc.getElementById("zen-spaces-search-no-results"),
itemSelector: ".zen-spaces-list-item",
});
const parts = this.#injectButton(aWindow);
if (!parts) return;
const { button, main, dropmarker } = parts;
main.addEventListener("click", event => {
if (event.button !== 0) return;
this.#openInWorkspace(aWindow, null);
});
dropmarker.addEventListener("click", event => {
if (event.button !== 0) return;
event.stopPropagation();
this.#openSpacesPopup(aWindow, popup, button);
});
}
#injectButton(aWindow) {
const doc = aWindow.document;
const target = doc.getElementById("nav-bar-customization-target");
if (!target) return null;
const button = doc.createXULElement("hbox");
button.id = "zen-little-window-send-to-window";
button.setAttribute("removable", "false");
const main = doc.createXULElement("hbox");
main.classList.add("zen-stw-main");
const prefix = doc.createXULElement("label");
prefix.classList.add("zen-stw-prefix");
prefix.setAttribute(
"data-l10n-id",
"zen-little-window-send-to-window-prefix"
);
const spaceName = doc.createXULElement("label");
spaceName.classList.add("zen-stw-space-name");
spaceName.setAttribute(
"value",
aWindow.gZenWorkspaces?.getActiveWorkspaceFromCache?.()?.name || ""
);
main.appendChild(prefix);
main.appendChild(spaceName);
const separator = doc.createXULElement("hbox");
separator.classList.add("zen-stw-separator");
const dropmarker = doc.createXULElement("hbox");
dropmarker.classList.add("zen-stw-dropmarker");
const dropIcon = doc.createXULElement("image");
dropIcon.classList.add("zen-stw-dropmarker-icon");
dropmarker.appendChild(dropIcon);
button.appendChild(main);
button.appendChild(separator);
button.appendChild(dropmarker);
target.appendChild(button);
return { button, main, dropmarker, spaceName };
}
#openSpacesPopup(aWindow, popup, anchor) {
const workspaces = lazy.ZenSessionStore.getClonedSpaces();
popup.populate(
workspaces.map(space => ({
label: space.name || space.uuid,
render: () => {
const node = aWindow.document.createXULElement("hbox");
const label = aWindow.document.createXULElement("label");
label.setAttribute("value", space.name || space.uuid);
label.classList.add("zen-spaces-list-item-label");
node.appendChild(label);
return node;
},
onPick: () => this.#openInWorkspace(aWindow, space.uuid),
}))
);
popup.open(anchor);
}
#openInWorkspace(aWindow, workspaceUuid) {
const url = aWindow.gURLBar?.value?.trim();
if (!url) return;
const args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
const urlString = Cc["@mozilla.org/supports-string;1"].createInstance(
Ci.nsISupportsString
);
urlString.data = url;
args.appendElement(urlString);
const opts = { args, zenSyncedWindow: true };
if (workspaceUuid) opts.zenInitialWorkspace = workspaceUuid;
const newWin = aWindow.OpenBrowserWindow(opts);
if (newWin) aWindow.close();
}
}
export const ZenSpacesSearch = new ZenSpacesSearchService();

View File

@@ -4,4 +4,5 @@
EXTRA_JS_MODULES.zen += [
"ZenLittleWindow.sys.mjs",
"ZenSpacesSearch.sys.mjs",
]

View File

@@ -8,10 +8,115 @@
* suppressed so the window acts as a quick search/launch box.
*/
:root[zen-little-window="true"] {
#zen-little-window-send-to-window {
align-items: stretch;
border-radius: var(--border-radius-small);
background: light-dark(white, rgba(0, 0, 0, 0.4));
height: 32px;
margin-inline: 4px;
overflow: hidden;
user-select: none;
align-self: center;
box-shadow: 0 0 1px 2px rgba(0, 0, 0, 0.01);
#urlbar {
width: 100%;
.zen-stw-main,
.zen-stw-dropmarker {
align-items: center;
padding-inline: 12px;
transition: background 80ms ease;
&:hover {
background: var(--toolbarbutton-active-background, color-mix(in srgb, currentColor 12%, transparent));
}
}
.zen-stw-prefix {
color: color-mix(in srgb, currentColor 60%, transparent);
margin-inline-end: 6px;
}
.zen-stw-space-name {
color: var(--zen-primary-color, currentColor);
font-weight: 600;
}
.zen-stw-separator {
width: 1px;
background: color-mix(in srgb, currentColor 18%, transparent);
margin-block: 6px;
}
.zen-stw-dropmarker {
padding-inline: 8px;
}
.zen-stw-dropmarker-icon {
width: 12px;
height: 12px;
-moz-context-properties: fill;
fill: color-mix(in srgb, currentColor 65%, transparent);
list-style-image: url("chrome://global/skin/icons/arrow-down.svg");
}
}
#zen-spaces-popup {
--arrowpanel-padding: 0;
--zen-spaces-list-padding: 6px;
padding: var(--zen-spaces-list-padding);
min-width: 250px;
.zen-spaces-list-header {
display: flex;
flex-direction: row;
padding: 6px;
border-bottom: 1px solid color-mix(in srgb, currentColor, transparent 90%);
}
.zen-spaces-list-search-icon {
width: 14px;
height: 14px;
margin-inline: 4px 6px;
-moz-context-properties: fill;
fill: currentColor;
opacity: 0.6;
}
#zen-spaces-list-search {
flex: 1;
background: transparent;
border: none;
outline: none;
color: inherit;
font: inherit;
}
.zen-spaces-list-item {
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
&:hover,
&[selected="true"] {
background: var(--toolbarbutton-hover-background);
}
&[active="true"]::after {
content: "•";
margin-inline-start: auto;
opacity: 0.7;
}
}
#zen-spaces-search-no-results {
padding: 12px;
opacity: 0.7;
justify-content: center;
}
}
:root[zen-little-window="true"] {
toolbarspring[cui-areatype="toolbar"],
#nav-bar-customization-target > .toolbarbutton-1[disabled="true"] {
display: none;
}
&[zen-has-empty-tab="true"] {
@@ -24,7 +129,8 @@
}
#urlbar[breakout-extend] {
min-width: 100% !important;
min-width: 600px !important;
max-width: 600px !important;
left: 50% !important;
top: 0 !important;
transform: translate(10px, 0) !important;
@@ -43,4 +149,14 @@
}
}
}
@media (-moz-platform: macos) {
#nav-bar {
padding-inline-start: 6px;
}
}
#urlbar-container {
--border-radius-medium: var(--border-radius-small);
}
}