gh-15266: Add support for sharing spaces, folders and split views (gh-15267)

This commit is contained in:
mr. m
2026-09-07 01:11:02 +02:00
committed by GitHub
parent abc69a1fcb
commit a323036995
31 changed files with 1673 additions and 130 deletions

View File

@@ -26,3 +26,5 @@ files:
translation: browser/browser/zen-space-routing.ftl
- source: en-US/browser/browser/zen-command-palette.ftl
translation: browser/browser/zen-command-palette.ftl
- source: en-US/browser/browser/zen-share.ftl
translation: browser/browser/zen-share.ftl

View File

@@ -0,0 +1,45 @@
# 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-share-space =
.label = Share Space…
zen-share-folder =
.label = Share Folder…
zen-share-split-view =
.label = Share Split View…
zen-share-link-copied-toast = Share link copied!
zen-share-link-expires-description = Anyone with the link can open it until { $date }
zen-share-link-permanent-description = Anyone with the link can open it
zen-share-error-toast = Couldn't create a share link
zen-share-error-auth-description = The share server rejected your key
zen-share-error-too-large-description = This share is too big to upload
zen-share-error-rate-limited-description = You're sharing too fast, try again in a minute
zen-share-error-network-description = Couldn't reach the share server
zen-share-error-empty-description = There's nothing shareable here
zen-share-error-server-description = The share server returned an error
zen-share-overlay-loading = Loading shared tabs…
zen-share-overlay-from-space = A Space from { $name }
zen-share-overlay-from-folder = A Folder from { $name }
zen-share-overlay-shared-with-you = Shared with you
zen-share-overlay-add-space = Add Space to Zen
zen-share-overlay-add-folder = Add Folder to Zen
zen-share-imported-toast = Added to your browser!
zen-share-import-error-toast = Couldn't import this share
zen-share-import-error-dead-description = The link has expired or doesn't exist
zen-share-import-error-invalid-description = The shared data is invalid
zen-share-confirm-window =
.title = Share with your buds
zen-share-confirm-title = Share with your buds
zen-share-confirm-dialog =
.buttonlabelaccept = Share
zen-share-confirm-description = Zen uploads a copy of these tabs to the share server and copies a link anyone can open. The link expires after 30 days.
zen-share-confirm-anonymous = Sharing is anonymous unless you add a name.
zen-share-confirm-name-input =
.placeholder = Your name (optional)
zen-share-confirm-dont-ask =
.label = Don't ask me again

View File

@@ -2,5 +2,18 @@
# 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/.
- name: zen.share.enabled
- name: zen.share.base-url
value: "https://share.zen-browser.app"
- name: zen.share.secret-key
value: ""
- name: zen.share.display-name
value: ""
# Allow zen.share.display-name to be synced across devices.
- name: services.sync.prefs.sync.zen.share.display-name
value: true
- name: zen.share.dont-ask-before-sharing
value: false

View File

@@ -21,3 +21,4 @@
#include ../../../zen/boosts/jar.inc.mn
#include ../../../zen/live-folders/jar.inc.mn
#include ../../../zen/space-routing/jar.inc.mn
#include ../../../zen/share/jar.inc.mn

View File

@@ -40,6 +40,7 @@
<command id="cmd_contextZenRemoveFromEssentials" />
<command id="cmd_zenCtxDeleteWorkspace" />
<command id="cmd_zenCtxShareWorkspace" />
<command id="cmd_zenUnloadWorkspace" />
<command id="cmd_zenUnloadAllOtherWorkspace" />
<command id="cmd_zenOpenSpaceRoutingSettings" />

View File

@@ -12,4 +12,5 @@
<link rel="localization" href="browser/zen-boosts.ftl"/>
<link rel="localization" href="browser/zen-live-folders.ftl"/>
<link rel="localization" href="browser/zen-space-routing.ftl"/>
<link rel="localization" href="browser/zen-share.ftl"/>
</linkset>

View File

@@ -32,6 +32,7 @@
<menuitem id="context_zenUnloadAllOtherWorkspace" data-l10n-id="zen-workspaces-panel-unload-others" command="cmd_zenUnloadAllOtherWorkspace"/>
<menuseparator/>
<menuitem data-l10n-id="zen-panel-ui-workspaces-create" command="cmd_zenOpenWorkspaceCreation"/>
<menuitem id="context_zenShareWorkspace" data-l10n-id="zen-share-space" command="cmd_zenCtxShareWorkspace"/>
<menuitem id="context_zenDeleteWorkspace" data-l10n-id="zen-workspaces-panel-context-delete" command="cmd_zenCtxDeleteWorkspace"/>
<menuseparator/>
<menuitem id="context_zenSpaceRoutingSettings" data-l10n-id="zen-space-routing-settings" command="cmd_zenOpenSpaceRoutingSettings"/>
@@ -55,6 +56,7 @@
<menupopup />
</menu>
<menuitem id="context_zenFolderToSpace" data-l10n-id="zen-folders-panel-convert-folder-to-space" />
<menuitem id="context_zenShareFolder" data-l10n-id="zen-share-folder"/>
<menuseparator />
<menuitem id="context_zenFolderUnpack" data-l10n-id="zen-folders-panel-unpack-folder"/>
<menuitem id="context_zenFolderDelete" data-l10n-id="zen-folders-panel-delete-folder"/>

View File

@@ -28,6 +28,7 @@
"chrome://browser/content/zen-components/ZenEmojiPicker.mjs",
"chrome://browser/content/zen-components/ZenLiveFoldersUI.mjs",
"chrome://browser/content/zen-components/ZenDownloadAnimation.mjs",
"resource:///modules/zen/share/ZenShareManager.mjs",
];
for (let script of scripts) {

View File

@@ -5,6 +5,7 @@
*/
:root:not([inDOMFullscreen="true"]):not([chromehidden~="location"]):not([chromehidden~="toolbar"]) {
& #tabbrowser-tabbox #tabbrowser-tabpanels .browserSidebarContainer {
--zen-transparent-browser-background: light-dark(rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.1));
overflow: clip;
:root:not([zen-no-padding="true"]) &:not(.zen-glance-overlay) {
@@ -18,7 +19,7 @@
}
&[transparent="true"] {
background: light-dark(rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.1));
background: var(--zen-transparent-browser-background);
}
}

View File

@@ -78,3 +78,52 @@ dialog::part(dialog-button) {
scale: 0.98;
}
}
:root {
--zen-accent-button-color: light-dark(
oklch(from var(--zen-primary-color) clamp(0.35, l, 0.6) c h),
oklch(from var(--zen-primary-color) clamp(0.7, l, 0.85) c h)
);
--zen-accent-button-background: light-dark(
oklch(from var(--zen-primary-color) clamp(0.35, l, 0.6) c h),
oklch(from var(--zen-primary-color) clamp(0.55, l, 0.7) c h)
);
}
.zen-big-accent-button {
appearance: none;
border: none;
border-radius: 12px;
padding: 1.2rem 1rem;
background: transparent;
color: var(--zen-accent-button-color);
font: inherit;
font-size: 1.2rem;
font-weight: 600;
cursor: pointer;
will-change: transform;
transition: scale 0.15s ease-out;
&:hover {
scale: 1.03;
filter: brightness(1.1);
}
&:active:hover {
scale: 1;
color: color-mix(in srgb, var(--zen-accent-button-color) 80%, transparent);
}
&.primary {
background: var(--zen-accent-button-background);
color: white;
&:hover {
filter: brightness(1.1);
}
&:active:hover {
color: rgba(255, 255, 255, 0.8);
}
}
}

View File

@@ -90,6 +90,9 @@ document.addEventListener(
case "cmd_zenCtxDeleteWorkspace":
gZenWorkspaces.contextDeleteWorkspace(event);
break;
case "cmd_zenCtxShareWorkspace":
gZenWorkspaces.contextShareWorkspace();
break;
case "cmd_zenChangeWorkspaceName":
gZenVerticalTabsManager.renameTabStart({
target: gZenWorkspaces.activeWorkspaceIndicator.querySelector(

View File

@@ -183,6 +183,9 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
case "context_zenFolderChangeIcon":
this.changeFolderUserIcon(this.#lastFolderContextMenu);
break;
case "context_zenShareFolder":
gZenShareManager.shareFolder(this.#lastFolderContextMenu);
break;
}
});
}

View File

@@ -4,4 +4,4 @@
content/browser/zen-components/ZenFolder.mjs (../../zen/folders/ZenFolder.mjs)
content/browser/zen-components/ZenFolders.mjs (../../zen/folders/ZenFolders.mjs)
content/browser/zen-styles/zen-folders.css (../../zen/folders/zen-folders.css)
* content/browser/zen-styles/zen-folders.css (../../zen/folders/zen-folders.css)

View File

@@ -0,0 +1,19 @@
/* 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/. */
& g,
& rect,
& path {
transition:
transform 0.3s cubic-bezier(0.42, 0, 0, 1),
opacity 0.3s cubic-bezier(0.42, 0, 0, 1);
}
&[state="open"] .back {
transform: skewX(16deg) translate(-4px, 2px) scale(0.85);
}
&[state="open"] :is(.front, .dots, .icon) {
transform: skewX(-16deg) translate(8px, 2px) scale(0.85);
}

View File

@@ -8,15 +8,17 @@
display: none !important;
}
:root {
--zen-folder-behind-bgcolor: light-dark(color-mix(in srgb, var(--zen-primary-color) 60%, gray), color-mix(in srgb, var(--zen-primary-color) 60%, #c1c1c1));
--zen-folder-front-bgcolor: light-dark(color-mix(in srgb, var(--zen-primary-color), white 70%), color-mix(in srgb, var(--zen-primary-color), black 40%));
--zen-folder-stroke: light-dark(color-mix(in srgb, var(--zen-primary-color) 50%, black), color-mix(in srgb, var(--zen-colors-primary) 15%, #ebebeb));
}
zen-folder {
display: flex;
flex-direction: column;
visibility: visible;
--zen-folder-behind-bgcolor: light-dark(color-mix(in srgb, var(--zen-primary-color) 60%, gray), color-mix(in srgb, var(--zen-primary-color) 60%, #c1c1c1));
--zen-folder-front-bgcolor: light-dark(color-mix(in srgb, var(--zen-primary-color), white 70%), color-mix(in srgb, var(--zen-primary-color), black 40%));
--zen-folder-stroke: light-dark(color-mix(in srgb, var(--zen-primary-color) 50%, black), color-mix(in srgb, var(--zen-colors-primary) 15%, #ebebeb));
-moz-window-dragging: no-drag;
transition: var(--zen-tabbox-element-indent-transition);
@@ -114,21 +116,7 @@ zen-folder {
transform: translate(8.5px, 10.5px);
}
& g,
& rect,
& path {
transition:
transform 0.3s cubic-bezier(0.42, 0, 0, 1),
opacity 0.3s cubic-bezier(0.42, 0, 0, 1);
}
&[state="open"] .back {
transform: skewX(16deg) translate(-4px, 2px) scale(0.85);
}
&[state="open"] :is(.front, .dots, .icon) {
transform: skewX(-16deg) translate(8px, 2px) scale(0.85);
}
%include zen-folder-icon.inc.css
& .icon {
opacity: 1;

View File

@@ -392,7 +392,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature {
return Promise.resolve(this.#currentTab);
}
// Existing-tab glances perform no navigation and are exempt; for fresh
// Existing-tab glances perform no navigation and are exempt. For fresh
// loads, refuse any URL the triggering principal isn't allowed to load
// (e.g. a web page linking to file://).
if (!existingTab && !this.#isGlanceLoadAllowed(data)) {

View File

@@ -0,0 +1,238 @@
// 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
});
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"gBaseUrl",
"zen.share.base-url",
"",
null,
value => value.trim().replace(/\/+$/, "")
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"gSecretKey",
"zen.share.secret-key",
""
);
const SCHEMA_URL = "resource:///modules/zen/share/share.schema.json";
// The server rejects bigger bodies at 25 MiB.
const MAX_SHARE_BYTES = 25 * 1024 * 1024;
const MAX_NAME_LENGTH = 200;
// Uploads can be tens of MiB, reads are small JSON.
const CREATE_TIMEOUT_MS = 120000;
const READ_TIMEOUT_MS = 30000;
// Public share page paths: /{slug}/{XXXX-XXXX-XXXX-XXXX id}.
const SHARE_PATH_RE =
/^\/(space|folder|split-view|split)\/([0-9A-Za-z]{4}-[0-9A-Za-z]{4}-[0-9A-Za-z]{4}-[0-9A-Za-z]{4})\/?$/;
export class ZenShareError extends Error {
/**
* @param {string} code - One of "invalid-document", "auth", "too-large",
* "rate-limited", "not-found", "network", "server".
* @param {string} message
*/
constructor(code, message) {
super(message);
this.name = "ZenShareError";
this.code = code;
}
}
class nsZenShareClient {
#validatorPromise = null;
get #baseUrl() {
return lazy.gBaseUrl;
}
#authHeaders() {
// A secret key authorizes on its own and makes the share permanent. Never
// send both keys.
if (lazy.gSecretKey) {
return { "x-secret-key": lazy.gSecretKey };
}
return { "x-api-key": AppConstants.MOZ_MOZILLA_API_KEY };
}
#getValidator() {
if (!this.#validatorPromise) {
this.#validatorPromise = fetch(SCHEMA_URL, { credentials: "omit" })
.then(response => response.json())
.then(schema => new lazy.JsonSchema.Validator(schema));
}
return this.#validatorPromise;
}
/**
* Validates a share document against the schema.
*
* @param {object} doc
* @returns {Promise<{valid: boolean, errors: object[]}>}
*/
async validateDocument(doc) {
const validator = await this.#getValidator();
return validator.validate(doc);
}
async #assertValidDocument(doc) {
const result = await this.validateDocument(doc);
if (!result.valid) {
console.error("ZenShare: document fails the schema", result.errors);
throw new ZenShareError(
"invalid-document",
"share document fails the schema"
);
}
}
async #request(url, options, timeoutMs = READ_TIMEOUT_MS) {
const controller = new AbortController();
const timer = lazy.setTimeout(() => controller.abort(), timeoutMs);
let response;
try {
response = await fetch(url, {
credentials: "omit",
signal: controller.signal,
...options,
});
} catch (e) {
throw new ZenShareError(
"network",
`could not reach ${url}: ${e.message}`
);
} finally {
lazy.clearTimeout(timer);
}
if (response.ok) {
return response;
}
let message = `${response.status}`;
try {
message = (await response.json()).error || message;
} catch (e) {}
switch (response.status) {
case 401:
case 403:
throw new ZenShareError("auth", message);
case 404:
throw new ZenShareError("not-found", message);
case 413:
throw new ZenShareError("too-large", message);
case 429:
throw new ZenShareError("rate-limited", message);
case 400:
case 422:
throw new ZenShareError("invalid-document", message);
default:
throw new ZenShareError("server", message);
}
}
/**
* Uploads a share document.
*
* @param {object} doc - A document conforming to share.schema.json.
* @param {{name?: string}} options - Optional sharer display name, shown on
* the share page as "A Space from {name}".
* @returns {Promise<object>} The create response, plus `link`: the absolute
* public page URL to hand to the user.
*/
async createShare(doc, { name } = {}) {
const base = this.#baseUrl;
const headers = this.#authHeaders();
await this.#assertValidDocument(doc);
const body = JSON.stringify(doc);
if (new TextEncoder().encode(body).length > MAX_SHARE_BYTES) {
throw new ZenShareError("too-large", "share document is over 25 MiB");
}
let url = `${base}/api/shares`;
if (name) {
url += `?name=${encodeURIComponent(name.slice(0, MAX_NAME_LENGTH))}`;
}
const response = await this.#request(
url,
{
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body,
},
CREATE_TIMEOUT_MS
);
const created = await response.json();
return { ...created, link: base + created.webUrl };
}
/**
* Recognizes public share page URLs on the configured server.
*
* @param {string} spec
* @returns {?{type: string, id: string}} null when the URL is not a share
* page (or no server is configured).
*/
parseShareUrl(spec) {
let uri;
let baseUri;
try {
uri = Services.io.newURI(spec);
baseUri = Services.io.newURI(this.#baseUrl);
} catch (e) {
return null;
}
if (uri.prePath !== baseUri.prePath) {
return null;
}
const match = uri.filePath.match(SHARE_PATH_RE);
if (!match) {
return null;
}
const slug = match[1];
const type = slug === "split" ? "split-view" : slug;
return { type, id: match[2].toUpperCase() };
}
/**
* Fetches everything needed to preview and import a share: the validated
* document, plus the sharer's display name.
*
* @param {{id: string}} share - As returned by parseShareUrl.
* @returns {Promise<{doc: object, name: ?string}>}
*/
async fetchSharePreview(share) {
const meta = await this.getShare(share.id);
await this.#assertValidDocument(meta.data);
return { doc: meta.data, name: meta.name || null };
}
/**
* Fetches a share with its metadata (authed):
* { id, name, createdAt, expiresAt, size, data }.
*
* @param {string} id
*/
async getShare(id) {
const response = await this.#request(`${this.#baseUrl}/api/shares/${id}`, {
headers: this.#authHeaders(),
});
return response.json();
}
}
export const ZenShareClient = new nsZenShareClient();

View File

@@ -0,0 +1,832 @@
// 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 { nsZenDOMOperatedFeature } from "chrome://browser/content/zen-components/ZenCommonUtils.mjs";
import { nsZenThemePicker } from "resource:///modules/zen/ZenGradientGenerator.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs",
ZenShareClient: "resource:///modules/zen/share/ZenShareClient.sys.mjs",
ZenShareError: "resource:///modules/zen/share/ZenShareClient.sys.mjs",
});
const SHARE_DOCUMENT_VERSION = "1";
const SHAREABLE_ICON_RE = /^(data:|chrome:)/;
const FOLDER_ICON_RE = /^chrome:\/\//;
/**
* Shares spaces, folders and split views through the share server, and
* imports them back from share links.
*/
class nsZenShareManager extends nsZenDOMOperatedFeature {
init() {
this.#insertSplitViewMenuItem();
delayedStartupPromise.then(() => {
gBrowser.addTabsProgressListener({
onLocationChange: (browser, webProgress, request, aLocation) => {
this.#onLocationChange(browser, webProgress, aLocation);
},
});
});
}
// Mark: sharing
async shareSpace(workspaceId) {
const workspace = gZenWorkspaces.getWorkspaceFromId(workspaceId);
const element = gZenWorkspaces.workspaceElement(workspaceId);
if (!workspace || !element) {
return;
}
const item = {
type: "space",
name: workspace.name || "Space",
items: [
...this.#serializeItems(element.pinnedTabsContainer.children, {
pinned: true,
}),
...this.#serializeItems(element.tabsContainer.children, {
pinned: false,
}),
],
};
const theme = this.#serializeTheme(workspace.theme);
if (theme) {
item.theme = theme;
}
await this.#createAndCopyLink(item);
}
async shareFolder(folder) {
if (!folder?.isZenFolder) {
return;
}
await this.#createAndCopyLink(this.#serializeFolder(folder));
}
async shareSplitView(group) {
if (!group?.hasAttribute("split-view-group")) {
return;
}
const item = this.#serializeSplitGroup(group);
if (!item) {
this.#showCreateError(
new lazy.ZenShareError("empty", "nothing to share")
);
return;
}
await this.#createAndCopyLink(item);
}
#serializeSplitGroup(group) {
const tabs = group.tabs
.filter(tab => !tab.hasAttribute("zen-empty-tab"))
.map(tab => this.#serializeTab(tab, { pinned: tab.pinned }))
.filter(Boolean);
return tabs.length >= 2
? { type: "splitView", tabs: tabs.slice(0, gZenViewSplitter.MAX_TABS) }
: null;
}
async #createAndCopyLink(item) {
if (!(await this.#confirmShare())) {
return;
}
try {
const created = await lazy.ZenShareClient.createShare(
{ version: SHARE_DOCUMENT_VERSION, shared: [item] },
{ name: this.#displayName() }
);
Cc["@mozilla.org/widget/clipboardhelper;1"]
.getService(Ci.nsIClipboardHelper)
.copyString(created.link);
// The description's l10n args come from the whole options object.
gZenUIManager.showToast("zen-share-link-copied-toast", {
descriptionId: created.expiresAt
? "zen-share-link-expires-description"
: "zen-share-link-permanent-description",
date: created.expiresAt
? new Date(created.expiresAt).toLocaleDateString()
: "",
timeout: 6000,
});
} catch (e) {
this.#showCreateError(e);
}
}
#displayName() {
return (
Services.prefs.getStringPref("zen.share.display-name", "") || undefined
);
}
async #confirmShare() {
if (
Services.prefs.getBoolPref("zen.share.dont-ask-before-sharing", false)
) {
return true;
}
const args = {
accepted: false,
name: Services.prefs.getStringPref("zen.share.display-name", ""),
};
await gDialogBox.open(
"chrome://browser/content/zen-components/windows/zen-share-confirm.xhtml",
args
);
if (args.accepted) {
Services.prefs.setStringPref("zen.share.display-name", args.name);
if (args.dontAsk) {
Services.prefs.setBoolPref("zen.share.dont-ask-before-sharing", true);
}
}
return args.accepted;
}
#showCreateError(error) {
console.error("ZenShare: could not create share:", error);
const descriptions = {
auth: "zen-share-error-auth-description",
"too-large": "zen-share-error-too-large-description",
"rate-limited": "zen-share-error-rate-limited-description",
network: "zen-share-error-network-description",
empty: "zen-share-error-empty-description",
};
gZenUIManager.showToast("zen-share-error-toast", {
descriptionId:
descriptions[error.code] || "zen-share-error-server-description",
timeout: 6000,
});
}
// Mark: serialization
#serializeItems(children, { pinned }) {
const items = [];
for (const child of children) {
if (gBrowser.isTab(child)) {
if (
child.hasAttribute("zen-empty-tab") ||
child.hasAttribute("zen-glance-tab")
) {
continue;
}
const tab = this.#serializeTab(child, { pinned });
if (tab) {
items.push(tab);
}
} else if (child.isZenFolder) {
items.push(this.#serializeFolder(child));
} else if (gBrowser.isTabGroup(child)) {
const split = child.hasAttribute("split-view-group")
? this.#serializeSplitGroup(child)
: null;
if (split) {
items.push(split);
continue;
}
// Plain tab groups (and degenerate splits) keep their member tabs.
for (const tab of child.tabs) {
if (tab.hasAttribute("zen-empty-tab")) {
continue;
}
const serialized = this.#serializeTab(tab, { pinned });
if (serialized) {
items.push(serialized);
}
}
}
}
return items;
}
#serializeFolder(folder) {
const item = {
type: "folder",
name: folder.label || "Folder",
items: this.#serializeItems(folder.allItems, { pinned: false }),
};
const icon = folder.iconURL;
if (icon) {
item.icon = icon;
}
return item;
}
#serializeTab(tab, { pinned = false } = {}) {
const url = tab.linkedBrowser?.currentURI?.spec;
if (!url || !/^https?:\/\//.test(url)) {
return null;
}
const item = { type: "tab", url };
const label = tab.label;
if (label) {
item.label = label;
}
const image = this.#serializeIcon(tab);
if (image) {
item.image = image;
}
if (pinned) {
item.isPinned = true;
}
return item;
}
#serializeIcon(tab) {
let icon = tab.getAttribute("image") || "";
if (icon.startsWith("moz-remote-image:")) {
try {
icon =
new URLSearchParams(Services.io.newURI(icon).query).get("url") || "";
} catch (e) {
return "";
}
}
return SHAREABLE_ICON_RE.test(icon) ? icon : "";
}
#serializeTheme(theme) {
if (theme?.type !== "gradient" || !Array.isArray(theme.gradientColors)) {
return null;
}
const gradientColors = theme.gradientColors
.map(color => this.#serializeGradientColor(color))
.filter(Boolean);
if (!gradientColors.length) {
return null;
}
return { type: "gradient", gradientColors };
}
#serializeGradientColor(color) {
let c = color?.c;
if (typeof c === "string") {
c = this.#parseCssColor(c);
} else if (Array.isArray(c)) {
c = c.slice(0, 3).map(Number);
}
if (
!Array.isArray(c) ||
c.length !== 3 ||
c.some(n => !Number.isFinite(n))
) {
return null;
}
const out = { c, type: typeof color.type === "string" ? color.type : "" };
if (typeof color.isCustom === "boolean") {
out.isCustom = color.isCustom;
}
if (typeof color.isPrimary === "boolean") {
out.isPrimary = color.isPrimary;
}
if (typeof color.algorithm === "string") {
out.algorithm = color.algorithm;
}
if (color.lightness !== undefined && color.lightness !== null) {
out.lightness = String(color.lightness);
}
if (
Number.isFinite(color.position?.x) &&
Number.isFinite(color.position?.y)
) {
out.position = { x: color.position.x, y: color.position.y };
}
return out;
}
#parseCssColor(value) {
const hex = value.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
if (hex) {
const full =
hex.length === 3 ? [...hex].map(ch => ch + ch).join("") : hex;
return [0, 2, 4].map(i => parseInt(full.slice(i, i + 2), 16));
}
const numbers = value
.match(/\d+(\.\d+)?/g)
?.slice(0, 3)
.map(Number);
return numbers?.length === 3 ? numbers : null;
}
// Mark: importing
#stylesLoaded = false;
// The overlay styles are only needed once a share link is opened.
#ensureStyles() {
if (this.#stylesLoaded) {
return;
}
this.#stylesLoaded = true;
window.windowUtils.loadSheetUsingURIString(
"chrome://browser/content/zen-styles/zen-share.css",
window.windowUtils.AUTHOR_SHEET
);
}
#setHostTabSubtitle(browser, subtitle) {
const tab = gBrowser.getTabForBrowser(browser);
if (!tab) {
return;
}
tab.setAttribute("zen-show-sublabel", subtitle);
document.l10n.setArgs(tab.querySelector(".zen-tab-sublabel"), {
tabSubtitle: subtitle,
});
}
#clearHostTabSubtitle(browser) {
const tab = gBrowser.getTabForBrowser(browser);
if (!tab?.hasAttribute("zen-show-sublabel")) {
return;
}
tab.removeAttribute("zen-show-sublabel");
document.l10n.setArgs(tab.querySelector(".zen-tab-sublabel"), {
tabSubtitle: "zen-default-pinned",
});
}
#onLocationChange(browser, webProgress, aLocation) {
if (!webProgress.isTopLevel) {
return;
}
if (browser._zenShareOverlay) {
browser._zenShareOverlay.remove();
delete browser._zenShareOverlay;
browser.removeAttribute("zen-share-overlay-showing");
this.#clearHostTabSubtitle(browser);
}
const share = lazy.ZenShareClient.parseShareUrl(aLocation.spec);
if (!share) {
return;
}
if (share.type === "split-view") {
// Split views are light enough to open right away, no preview step.
this.#openSharedSplitView(browser, share);
} else {
this.#showImportOverlay(browser, share);
}
}
async #openSharedSplitView(browser, share) {
let item;
const shareTab = gBrowser.getTabForBrowser(browser);
shareTab.style.display = "none";
const failure = () => {
shareTab.style.removeProperty("display");
};
try {
const { doc } = await lazy.ZenShareClient.fetchSharePreview(share);
item = doc.shared.find(entry => entry.type === "splitView");
} catch (e) {
console.error("ZenShare: could not load shared split view:", e);
const descriptions = {
"not-found": "zen-share-import-error-dead-description",
"invalid-document": "zen-share-import-error-invalid-description",
};
gZenUIManager.showToast("zen-share-import-error-toast", {
descriptionId:
descriptions[e.code] || "zen-share-error-network-description",
timeout: 6000,
});
failure();
return;
}
if (!item) {
failure();
return;
}
const workspaceId = gZenWorkspaces.activeWorkspace;
const pinned = item.tabs.every(child => child.isPinned);
const tabs = item.tabs.map(child => {
// Eager browsers so every pane renders as soon as the split shows.
const tab = this.#importTab(child, workspaceId, { lazyBrowser: false });
if (pinned) {
gBrowser.pinTab(tab);
}
this.#placeTab(tab, workspaceId, pinned);
return tab;
});
gZenViewSplitter.splitTabs(tabs, "grid", 0);
gBrowser.removeTab(shareTab, { closeWindowWithLastTab: false });
// Shake the new split group in the sidebar.
const group = tabs[0]?.group;
if (group?.hasAttribute("split-view-group")) {
requestAnimationFrame(() => {
gZenUIManager.motion
.animate(
group,
{ x: [-14, 0] },
{ type: "spring", bounce: 0.8, duration: 1.2 }
)
.then(() => {
group.style.removeProperty("transform");
});
});
}
}
async #showImportOverlay(browser, share) {
const stack = browser.closest(".browserStack");
if (!stack) {
return;
}
this.#ensureStyles();
// Only the status text shows until the preview loads.
const overlay = window.MozXULElement.parseXULToFragment(`
<html:div class="zen-share-overlay">
<html:div class="zen-share-overlay-card">
<html:div class="zen-share-overlay-items"/>
<html:div class="zen-share-overlay-status"
data-l10n-id="zen-share-overlay-loading"/>
<html:div class="zen-share-overlay-footer" hidden="hidden">
<html:div class="zen-share-overlay-badge-stack">
<html:div class="zen-share-overlay-badge-sheet second"/>
<html:div class="zen-share-overlay-badge-sheet first"/>
<html:div class="zen-share-overlay-badge">
<html:img class="zen-share-overlay-badge-icon"
draggable="false"/>
<html:div class="zen-share-overlay-badge-text">
<html:div class="zen-share-overlay-badge-title"/>
<html:div class="zen-share-overlay-badge-subtitle"/>
</html:div>
</html:div>
</html:div>
</html:div>
</html:div>
<html:button class="zen-big-accent-button primary zen-share-overlay-add"
hidden="hidden" disabled="disabled"/>
</html:div>
`).firstElementChild;
const items = overlay.querySelector(".zen-share-overlay-items");
const statusEl = overlay.querySelector(".zen-share-overlay-status");
const footer = overlay.querySelector(".zen-share-overlay-footer");
const badgeIcon = overlay.querySelector(".zen-share-overlay-badge-icon");
const badgeTitle = overlay.querySelector(".zen-share-overlay-badge-title");
const badgeSubtitle = overlay.querySelector(
".zen-share-overlay-badge-subtitle"
);
const button = overlay.querySelector(".zen-share-overlay-add");
stack.appendChild(overlay);
browser._zenShareOverlay = overlay;
browser.setAttribute("zen-share-overlay-showing", "true");
let preview;
try {
preview = await lazy.ZenShareClient.fetchSharePreview(share);
} catch (e) {
console.error("ZenShare: could not load share preview:", e);
const descriptions = {
"not-found": "zen-share-import-error-dead-description",
"invalid-document": "zen-share-import-error-invalid-description",
};
document.l10n.setAttributes(
statusEl,
descriptions[e.code] || "zen-share-error-network-description"
);
return;
}
if (browser._zenShareOverlay !== overlay) {
// Navigated away while loading.
return;
}
const { doc, name: sharerName } = preview;
const first = doc.shared[0];
const type = first.type;
badgeTitle.textContent = first.name;
this.#renderPreviewRows(items, first.items);
if (type === "folder") {
// The real zen folder icon, with the shared custom icon when set.
const folderIcon = this.#buildFolderIcon(first.icon);
folderIcon.classList.add("zen-share-overlay-badge-icon");
folderIcon.setAttribute("state", "open");
badgeIcon.replaceWith(folderIcon);
} else {
badgeIcon.src = "chrome://browser/skin/zen-icons/duplicate-tab.svg";
}
if (sharerName) {
document.l10n.setAttributes(
badgeSubtitle,
`zen-share-overlay-from-${type}`,
{ name: sharerName }
);
} else {
document.l10n.setAttributes(
badgeSubtitle,
"zen-share-overlay-shared-with-you"
);
}
// Mirror the badge subtitle under the tab's label, like live folders do.
const [tabSubtitle] = await document.l10n.formatValues([
sharerName
? { id: `zen-share-overlay-from-${type}`, args: { name: sharerName } }
: { id: "zen-share-overlay-shared-with-you" },
]);
this.#setHostTabSubtitle(browser, tabSubtitle);
statusEl.remove();
footer.hidden = false;
document.l10n.setAttributes(button, `zen-share-overlay-add-${type}`);
button.hidden = false;
button.disabled = false;
button.addEventListener("click", async () => {
button.disabled = true;
try {
await this.#importDocument(doc);
gZenUIManager.showToast("zen-share-imported-toast");
const tab = gBrowser.getTabForBrowser(browser);
if (tab) {
gBrowser.removeTab(tab, { closeWindowWithLastTab: false });
}
} catch (e) {
console.error("ZenShare: could not import share:", e);
gZenUIManager.showToast("zen-share-import-error-toast", {
timeout: 6000,
});
button.disabled = false;
}
});
}
#renderPreviewRows(container, entries) {
for (const entry of entries) {
if (entry.type === "folder") {
container.appendChild(this.#buildFolderRow(entry));
} else if (entry.type === "splitView") {
const split = window.MozXULElement.parseXULToFragment(`
<html:div class="zen-share-overlay-split"/>
`).firstElementChild;
for (const tab of entry.tabs) {
split.appendChild(this.#buildTabRow(tab));
}
container.appendChild(split);
} else {
container.appendChild(this.#buildTabRow(entry));
}
}
}
#buildTabRow(entry) {
const row = window.MozXULElement.parseXULToFragment(`
<html:div class="zen-share-overlay-row">
<html:img class="zen-share-overlay-row-icon" draggable="false"/>
<html:div class="zen-share-overlay-row-label"/>
</html:div>
`).firstElementChild;
row.title = entry.url;
row.querySelector(".zen-share-overlay-row-icon").src =
entry.image && SHAREABLE_ICON_RE.test(entry.image)
? entry.image
: `page-icon:${entry.url}`;
row.querySelector(".zen-share-overlay-row-label").textContent =
entry.label || entry.url;
row.addEventListener("click", () => {
const stackRect = row.closest(".browserStack").getBoundingClientRect();
const data = {
url: entry.url,
clientX: stackRect.width / 2,
clientY: stackRect.height / 2,
width: 0,
height: 0,
triggeringPrincipal:
Services.scriptSecurityManager.getSystemPrincipal(),
};
gZenGlanceManager.lastLinkClickData = data;
gZenGlanceManager.openGlance(data);
});
return row;
}
#buildFolderRow(entry) {
const wrapper = window.MozXULElement.parseXULToFragment(`
<html:div class="zen-share-overlay-folder" collapsed="true">
<html:div class="zen-share-overlay-row">
<html:div class="zen-share-overlay-row-label"/>
</html:div>
<html:div class="zen-share-overlay-children-clip">
<html:div class="zen-share-overlay-children"/>
</html:div>
</html:div>
`).firstElementChild;
const row = wrapper.querySelector(".zen-share-overlay-row");
const label = row.querySelector(".zen-share-overlay-row-label");
label.textContent = entry.name;
const icon = this.#buildFolderIcon(entry.icon);
row.insertBefore(icon, label);
this.#renderPreviewRows(
wrapper.querySelector(".zen-share-overlay-children"),
entry.items
);
row.addEventListener("click", () => {
const collapsed = wrapper.toggleAttribute("collapsed");
if (collapsed) {
icon.removeAttribute("state");
} else {
icon.setAttribute("state", "open");
}
});
return wrapper;
}
#buildFolderIcon(customIcon) {
const { nsZenFolder } = ChromeUtils.importESModule(
"chrome://browser/content/zen-components/ZenFolder.mjs",
{ global: "current" }
);
const svg = nsZenFolder.rawIcon.cloneNode(true);
svg.classList.add("zen-share-overlay-folder-icon");
if (customIcon && FOLDER_ICON_RE.test(customIcon)) {
svg.querySelector(".icon image").setAttribute("href", customIcon);
}
return svg;
}
async #importDocument(doc) {
for (const item of doc.shared) {
switch (item.type) {
case "space":
await this.#importSpace(item);
break;
case "folder":
this.#importFolder(item, gZenWorkspaces.activeWorkspace);
break;
case "splitView":
this.#importSplitView(item);
break;
}
}
}
async #importSpace(item) {
const workspace = await gZenWorkspaces.createAndSaveWorkspace(
item.name,
undefined,
false,
0,
{
beforeChangeCallback: async newWorkspace => {
for (const child of item.items) {
if (child.type === "tab") {
const tab = this.#importTab(child, newWorkspace.uuid);
if (child.isPinned) {
gBrowser.pinTab(tab);
}
this.#placeTab(tab, newWorkspace.uuid, !!child.isPinned);
} else if (child.type === "folder") {
this.#importFolder(child, newWorkspace.uuid);
} else if (child.type === "splitView") {
this.#importSplitView(child, newWorkspace.uuid);
}
}
},
}
);
if (!workspace) {
return;
}
workspace.name = item.name;
const theme = item.theme;
if (theme) {
workspace.theme = nsZenThemePicker.getTheme(
theme.gradientColors.map(color => this.#importGradientColor(color))
);
}
gZenWorkspaces.saveWorkspace(workspace);
gZenThemePicker.onWorkspaceChange(workspace);
gZenStartup.playWindowSweepAnimation();
}
#importGradientColor(color) {
if (color.isCustom) {
const [r, g, b] = color.c;
return { ...color, c: `rgb(${r}, ${g}, ${b})` };
}
return color;
}
#importFolder(item, workspaceId, parentFolder = null) {
const folder = gZenFolders.createFolder([], {
label: item.name,
collapsed: true,
workspaceId,
insertAfter: parentFolder
? parentFolder.groupContainer.lastElementChild
: null,
});
if (item.icon && FOLDER_ICON_RE.test(item.icon)) {
gZenFolders.setFolderUserIcon(folder, item.icon);
}
for (const child of item.items) {
if (child.type === "tab") {
const tab = this.#importTab(child, workspaceId);
gBrowser.pinTab(tab);
folder.addTabs([tab]);
} else if (child.type === "folder") {
this.#importFolder(child, workspaceId, folder);
} else if (child.type === "splitView") {
this.#importSplitView(child, workspaceId, folder);
}
}
return folder;
}
#importSplitView(item, workspaceId, folder = null) {
workspaceId ??= gZenWorkspaces.activeWorkspace;
const pinned = item.tabs.every(child => child.isPinned);
const tabs = item.tabs.map(child => {
const tab = this.#importTab(child, workspaceId);
if (folder) {
gBrowser.pinTab(tab);
folder.addTabs([tab]);
} else {
if (pinned) {
gBrowser.pinTab(tab);
}
this.#placeTab(tab, workspaceId, pinned);
}
return tab;
});
gZenViewSplitter.splitTabs(tabs, "grid", -1);
}
// Deterministic placement at the end of the right section;
// moveTabToWorkspace's anchor follows the new-tab-button position pref.
#placeTab(tab, workspaceId, pinned) {
const element = gZenWorkspaces.workspaceElement(workspaceId);
const container = pinned
? element.pinnedTabsContainer
: element.tabsContainer;
const anchor = pinned
? container.querySelector(".pinned-tabs-container-separator")
: container.querySelector("#tabbrowser-arrowscrollbox-periphery");
gBrowser.zenHandleTabMove(tab, () => {
tab.setAttribute("zen-workspace-id", workspaceId);
container.insertBefore(tab, anchor);
});
}
#importTab(item, workspaceId, { lazyBrowser = true } = {}) {
const tab = gBrowser.addTrustedTab(item.url, {
createLazyBrowser: lazyBrowser,
inBackground: true,
skipAnimation: true,
skipBackgroundNotify: true,
lazyTabTitle: item.label || undefined,
skipRoute: true,
});
if (workspaceId) {
tab.setAttribute("zen-workspace-id", workspaceId);
}
if (item.image && SHAREABLE_ICON_RE.test(item.image)) {
try {
gBrowser.setIcon(tab, item.image);
lazy.TabStateCache.update(tab.linkedBrowser.permanentKey, {
image: null,
});
} catch (e) {
console.error("ZenShare: could not set imported tab icon:", e);
}
}
return tab;
}
// Mark: split view context menu
#insertSplitViewMenuItem() {
const fragment = window.MozXULElement.parseXULToFragment(`
<menuitem id="context_zenShareSplitView"
data-lazy-l10n-id="zen-share-split-view"
hidden="true"/>
`);
document.getElementById("context_moveTabToSplitView").before(fragment);
const menuItem = document.getElementById("context_zenShareSplitView");
menuItem.addEventListener("command", () => {
const group = TabContextMenu.contextTab?.group;
if (group?.hasAttribute("split-view-group")) {
this.shareSplitView(group);
}
});
document
.getElementById("tabContextMenu")
.addEventListener("popupshowing", () => {
const contextTab = TabContextMenu.contextTab;
const selectedTabs = contextTab?.multiselected
? gBrowser.selectedTabs
: [contextTab];
menuItem.hidden =
!contextTab ||
!selectedTabs.every(tab =>
tab?.group?.hasAttribute("split-view-group")
);
});
}
}
window.gZenShareManager = new nsZenShareManager();

7
src/zen/share/jar.inc.mn Normal file
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/.
* content/browser/zen-styles/zen-share.css (../../zen/share/zen-share.css)
* content/browser/zen-components/windows/zen-share-confirm.xhtml (../../zen/share/zen-share-confirm.inc.xhtml)
content/browser/zen-components/windows/zen-share-confirm.js (../../zen/share/zen-share-confirm.js)

View File

@@ -4,4 +4,6 @@
EXTRA_JS_MODULES.zen.share += [
"share.schema.json",
"ZenShareClient.sys.mjs",
"ZenShareManager.mjs",
]

View File

@@ -22,21 +22,10 @@
"oneOf": [
{ "$ref": "#/$defs/space" },
{ "$ref": "#/$defs/folder" },
{ "$ref": "#/$defs/splitView" },
{ "$ref": "#/$defs/gradientPreset" }
{ "$ref": "#/$defs/splitView" }
]
},
"gradientPreset": {
"type": "object",
"properties": {
"type": { "const": "gradientPreset" },
"theme": { "$ref": "#/$defs/theme" }
},
"required": ["type", "theme"],
"additionalProperties": false
},
"space": {
"type": "object",
"properties": {
@@ -144,11 +133,19 @@
},
"spaceItem": {
"oneOf": [{ "$ref": "#/$defs/tab" }, { "$ref": "#/$defs/folder" }]
"oneOf": [
{ "$ref": "#/$defs/tab" },
{ "$ref": "#/$defs/folder" },
{ "$ref": "#/$defs/splitView" }
]
},
"folderItem": {
"oneOf": [{ "$ref": "#/$defs/tab" }, { "$ref": "#/$defs/folder" }]
"oneOf": [
{ "$ref": "#/$defs/tab" },
{ "$ref": "#/$defs/folder" },
{ "$ref": "#/$defs/splitView" }
]
}
}
}

View File

@@ -0,0 +1,61 @@
#filter substitution
<?xml version="1.0"?>
# -*- Mode: HTML -*-
#
# 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/.
<!DOCTYPE window>
<window
xmlns:html="http://www.w3.org/1999/xhtml"
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
id="zenShareConfirmDialog"
data-l10n-id="zen-share-confirm-window"
data-l10n-attrs="title"
>
<dialog
buttons="accept,cancel"
data-l10n-id="zen-share-confirm-dialog"
data-l10n-attrs="buttonlabelaccept"
>
<linkset>
<html:link
rel="stylesheet"
href="chrome://global/skin/global.css"
type="text/css"
/>
<html:link
rel="stylesheet"
href="chrome://browser/content/zen-styles/zen-theme.css"
type="text/css"
/>
<html:link rel="localization" href="browser/zen-share.ftl" />
</linkset>
<script src="chrome://browser/content/zen-components/windows/zen-share-confirm.js"></script>
<vbox>
<html:h2
data-l10n-id="zen-share-confirm-title"
></html:h2>
<description data-l10n-id="zen-share-confirm-description" />
<description
style="margin: 12px 0 16px;"
data-l10n-id="zen-share-confirm-anonymous"
/>
<html:input
id="zen-share-confirm-name"
type="text"
maxlength="200"
data-l10n-id="zen-share-confirm-name-input"
/>
<checkbox
id="zen-share-confirm-dont-ask"
data-l10n-id="zen-share-confirm-dont-ask"
/>
</vbox>
</dialog>
</window>

View File

@@ -0,0 +1,27 @@
/* 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/. */
"use strict";
{
const args = window.arguments?.[0] || {};
window.addEventListener(
"load",
() => {
const nameInput = document.getElementById("zen-share-confirm-name");
nameInput.value = args.name || "";
nameInput.focus();
document.addEventListener("dialogaccept", () => {
args.accepted = true;
args.name = nameInput.value.trim();
args.dontAsk = document.getElementById(
"zen-share-confirm-dont-ask"
).checked;
});
},
{ once: true }
);
}

259
src/zen/share/zen-share.css Normal file
View File

@@ -0,0 +1,259 @@
/* 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-share-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
padding: 32px;
background: var(--zen-transparent-browser-background);
}
browser[zen-share-overlay-showing] {
-moz-subtree-hidden-only-visually: 1;
}
.zen-share-overlay-card {
display: flex;
flex-direction: column;
width: 325px;
max-height: 76%;
border-radius: 30px;
background: var(--zen-dialog-background);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.07),
0 14px 40px -10px rgba(0, 0, 0, 0.28);
overflow: hidden;
animation: zen-share-overlay-enter 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.zen-share-overlay-add {
min-width: 260px;
max-width: 100%;
padding: 16px 48px;
border-radius: 18px;
font-size: 1.1em;
animation: zen-share-overlay-enter 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) 0.06s
both;
&[disabled] {
opacity: 0.5;
pointer-events: none;
}
}
@keyframes zen-share-overlay-enter {
from {
opacity: 0;
transform: translateY(14px) scale(0.98);
}
}
.zen-share-overlay-items {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
scrollbar-width: none;
padding: 22px 20px 16px;
mask-image: linear-gradient(
transparent,
black 24px,
black calc(100% - 20px),
transparent
);
}
.zen-share-overlay-row {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 14px;
border-radius: 12px;
min-width: 0;
&:hover {
background: color-mix(in srgb, currentColor 7%, transparent);
}
&:active {
background: color-mix(in srgb, currentColor 11%, transparent);
}
}
.zen-share-overlay-row-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
border-radius: 4px;
}
.zen-share-overlay-folder-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
margin: -3px;
%include ../folders/zen-folder-icon.inc.css
/* The dots stand in for the icon on active sidebar folders; the preview
* always shows the plain folder. */
& .dots {
opacity: 0;
}
}
.zen-share-overlay-row-label {
font-size: 1.1em;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.zen-share-overlay-split {
display: flex;
gap: 10px;
min-width: 0;
& .zen-share-overlay-row {
flex: 1 1 0;
padding: 6px 14px;
border-radius: 8px;
}
& .zen-share-overlay-row:not(:first-child) {
position: relative;
&::before {
content: "";
position: absolute;
inset-block: 6px;
inset-inline-start: -5.5px;
width: 1px;
background: var(--zen-colors-border);
}
}
}
.zen-share-overlay-children-clip {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.25s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.zen-share-overlay-folder[collapsed] > .zen-share-overlay-children-clip {
grid-template-rows: 0fr;
}
.zen-share-overlay-children {
display: flex;
flex-direction: column;
gap: 8px;
margin-inline-start: 28px;
min-height: 0;
overflow: hidden;
}
.zen-share-overlay-status {
padding: 28px;
text-align: center;
opacity: 0.7;
}
.zen-share-overlay-footer {
position: relative;
isolation: isolate;
display: flex;
align-items: center;
margin-top: auto;
padding: 22px 20px 20px;
border-top: 2px dashed
color-mix(in srgb, var(--zen-primary-color) 22%, var(--zen-colors-border));
background: color-mix(
in srgb,
var(--zen-primary-color) 7%,
var(--zen-dialog-background)
);
}
.zen-share-overlay-badge-stack {
position: relative;
flex: 1;
min-width: 0;
}
.zen-share-overlay-badge {
position: relative;
z-index: 2;
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
padding: 14px 18px;
border-radius: 18px;
border: 1px solid
light-dark(
color-mix(in srgb, currentColor 12%, var(--zen-colors-border)),
color-mix(in srgb, currentColor 8%, transparent)
);
background: light-dark(#ffffff, #1c1c1e);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07);
}
.zen-share-overlay-badge-sheet {
position: absolute;
height: 26px;
border-radius: 14px;
border: 1px solid
light-dark(
color-mix(in srgb, currentColor 12%, var(--zen-colors-border)),
color-mix(in srgb, currentColor 8%, transparent)
);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07);
&.first {
z-index: 1;
inset-inline: 10px;
top: -7px;
background: color-mix(in srgb, currentColor 2%, light-dark(#ffffff, #1c1c1e));
}
&.second {
z-index: 0;
inset-inline: 22px;
top: -13px;
background: color-mix(in srgb, currentColor 4%, light-dark(#ffffff, #1c1c1e));
}
}
.zen-share-overlay-badge-icon {
width: 26px;
height: 26px;
flex-shrink: 0;
-moz-context-properties: fill;
fill: color-mix(in srgb, var(--zen-accent-button-color) 60%, currentColor);
}
.zen-share-overlay-badge-text {
min-width: 0;
color: color-mix(in srgb, var(--zen-accent-button-color) 30%, currentColor);
}
.zen-share-overlay-badge-title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.zen-share-overlay-badge-subtitle {
opacity: 0.6;
font-size: smaller;
}

View File

@@ -2888,6 +2888,12 @@ class nsZenWorkspaces {
});
}
contextShareWorkspace() {
const workspaceId =
this.#contextMenuData?.workspaceId || this.activeWorkspace;
gZenShareManager.shareSpace(workspaceId);
}
async contextDeleteWorkspace() {
const workspaceId =
this.#contextMenuData?.workspaceId || this.activeWorkspace;

View File

@@ -329,7 +329,6 @@ zen-workspace {
color-mix(in srgb, rgba(255, 255, 255, 0.18) 95%, var(--zen-primary-color))
) !important;
--tab-box-shadow-selected: 0 0.8px 1.5px 0px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.05)) !important;
--tab-selected-textcolor: color-mix(in srgb, var(--toolbox-textcolor) 95%, var(--zen-primary-color)) !important;
@media not (prefers-reduced-motion: reduce) {
transition: padding-top 0.1s;

View File

@@ -75,7 +75,7 @@ class ZenSpacesSyncStore extends Store {
}
/**
* Tracker for the Spaces engine. The model diffs the current projections
* Tracker for the Spaces engine. The model diffs the current projections
* against the last-uploaded snapshot. The score is only bumped when
* something actually differs.
*/

View File

@@ -514,30 +514,52 @@ class nsZenSpacesSyncModel {
const map = new Map();
const pending = new Set();
const ctx = this.#projectionContext(sidebar);
const { tabs, folders, splits, splitParents, splitWs } = ctx;
const spaces = sidebar.spaces || [];
if (!lazy.syncNormalTabs) {
// Items excluded only by the normal-tabs option are held back, not
// deleted. Flipping the option off must not tombstone them remotely.
const held = new Set();
for (const tab of ctx.allTabs) {
if (this.#isTabRecordMaterial(tab) && !this.#isSyncableTab(tab)) {
held.add(tab.zenSyncId);
pending.add(tab.zenSyncId);
}
}
for (const split of sidebar.splitViewData || []) {
if (
split?.groupId &&
!ctx.splitIds.has(split.groupId) &&
Array.isArray(split.tabs) &&
split.tabs.some(id => held.has(id))
) {
pending.add(split.groupId);
}
this.#collectHeldIds(ctx, sidebar, pending);
this.#projectContainers(map);
this.#projectSpaces(map, ctx, spaces);
this.#projectFolders(map, ctx, pending);
this.#projectTabs(map, ctx);
this.#projectSplits(map, ctx);
this.#projectLayout(map, ctx, spaces);
this.#cache = { stamp, map, pending };
return map;
}
/**
* Items excluded only by the normal-tabs option are held back, not
* deleted. Flipping the option off must not tombstone them remotely.
*
* @param {object} ctx - The projection context.
* @param {object} sidebar - The collected sidebar data.
* @param {Set<string>} pending - Receives the held-back ids.
*/
#collectHeldIds(ctx, sidebar, pending) {
if (lazy.syncNormalTabs) {
return;
}
const held = new Set();
for (const tab of ctx.allTabs) {
if (this.#isTabRecordMaterial(tab) && !this.#isSyncableTab(tab)) {
held.add(tab.zenSyncId);
pending.add(tab.zenSyncId);
}
}
for (const split of sidebar.splitViewData || []) {
if (
split?.groupId &&
!ctx.splitIds.has(split.groupId) &&
Array.isArray(split.tabs) &&
split.tabs.some(id => held.has(id))
) {
pending.add(split.groupId);
}
}
}
#projectContainers(map) {
for (const identity of lazy.ContextualIdentityService.getPublicIdentities()) {
if (!identity.name) {
continue;
@@ -558,8 +580,9 @@ class nsZenSpacesSyncModel {
},
});
}
}
const spaces = sidebar.spaces || [];
#projectSpaces(map, ctx, spaces) {
for (const space of spaces) {
if (!space?.uuid) {
continue;
@@ -579,8 +602,10 @@ class nsZenSpacesSyncModel {
},
});
}
}
for (const folder of folders) {
#projectFolders(map, ctx, pending) {
for (const folder of ctx.folders) {
const fid = folder.id;
let live = null;
if (folder.isLiveFolder) {
@@ -607,11 +632,11 @@ class nsZenSpacesSyncModel {
},
});
}
}
this.#projectTabs(map, ctx);
const tabById = new Map(tabs.map(t => [t.zenSyncId, t]));
for (const split of splits) {
#projectSplits(map, ctx) {
const tabById = new Map(ctx.tabs.map(t => [t.zenSyncId, t]));
for (const split of ctx.splits) {
const member = tabById.get(split.tabs[0]);
map.set(split.groupId, {
kind: RECORD_KINDS.SPLIT,
@@ -620,34 +645,33 @@ class nsZenSpacesSyncModel {
gridType: split.gridType || "grid",
pinned: !!(member?.pinned || member?.zenEssential),
tabs: [...split.tabs],
workspaceUuid: splitWs.get(split.groupId) ?? null,
folderId: splitParents.get(split.groupId) || null,
workspaceUuid: ctx.splitWs.get(split.groupId) ?? null,
folderId: ctx.splitParents.get(split.groupId) || null,
},
});
}
}
if (spaces.length) {
const essentials = {};
for (const tab of tabs) {
if (!tab.zenEssential) {
continue;
}
const key =
this.guidForContextId(tab.userContextId, { create: true }) ||
"default";
(essentials[key] ||= []).push(tab.zenSyncId);
#projectLayout(map, ctx, spaces) {
if (!spaces.length) {
return;
}
const essentials = {};
for (const tab of ctx.tabs) {
if (!tab.zenEssential) {
continue;
}
map.set(LAYOUT_RECORD_ID, {
kind: RECORD_KINDS.LAYOUT,
data: {
spaces: spaces.map(s => s.uuid).filter(Boolean),
essentials,
},
});
const key =
this.guidForContextId(tab.userContextId, { create: true }) || "default";
(essentials[key] ||= []).push(tab.zenSyncId);
}
this.#cache = { stamp, map, pending };
return map;
map.set(LAYOUT_RECORD_ID, {
kind: RECORD_KINDS.LAYOUT,
data: {
spaces: spaces.map(s => s.uuid).filter(Boolean),
essentials,
},
});
}
/**

View File

@@ -281,8 +281,8 @@
for (const button of this.currentPage.buttons) {
const element = document.createElement("button");
element.className = button.primary
? "zen-welcome-button primary"
: "zen-welcome-button";
? "zen-big-accent-button primary"
: "zen-big-accent-button";
document.l10n.setAttributes(element, button.l10n);
element.addEventListener("click", () => {
if (button.onclick?.(this) !== false) {

View File

@@ -40,14 +40,7 @@
}
#zen-welcome {
--zen-welcome-accent: light-dark(
oklch(from var(--zen-primary-color) clamp(0.35, l, 0.6) c h),
oklch(from var(--zen-primary-color) clamp(0.7, l, 0.85) c h)
);
--zen-welcome-accent-button: light-dark(
oklch(from var(--zen-primary-color) clamp(0.35, l, 0.6) c h),
oklch(from var(--zen-primary-color) clamp(0.55, l, 0.7) c h)
);
--zen-welcome-accent: var(--zen-accent-button-color);
--zen-welcome-sidebar-bg: light-dark(#ffffff, #1c1c1e);
--zen-welcome-content-bg: light-dark(#f6f6f8f5, #2b2b30ed);
--zen-welcome-surface-bg: light-dark(#ffffff, #1c1c1e);
@@ -62,39 +55,6 @@
);
}
.zen-welcome-button {
appearance: none;
border: none;
border-radius: 12px;
padding: 1.2rem 1rem;
background: transparent;
color: var(--zen-welcome-accent);
font: inherit;
font-size: 1.2rem;
font-weight: 600;
cursor: pointer;
will-change: transform;
transition: scale 0.15s ease-out;
&:hover {
scale: 1.03;
filter: brightness(1.1);
}
&:active {
scale: 1;
}
&.primary {
background: var(--zen-welcome-accent-button);
color: white;
&:hover {
filter: brightness(1.1);
}
}
}
#zen-welcome-start {
flex-direction: column;
-moz-window-dragging: drag;

View File

@@ -40,6 +40,8 @@ export default [
"gZenViewSplitter",
"gZenShareManager",
"gZenSpaceRoutingManager",
"Ci",