mirror of
https://github.com/zen-browser/desktop.git
synced 2026-01-06 21:37:50 +00:00
Problem: The Zen browser was experiencing random crashes when switching tabs, particularly when creating a new tab with Ctrl+T and typing a URL. The crash logs showed "CompositorBridgeChild receives IPC close with reason=AbnormalShutdown" errors, indicating issues with the graphics compositor during tab switching operations. This was caused by race conditions in the tab switching code and lack of proper error handling when dealing with browser elements that might be in an invalid state. Solution: Instead of just catching errors, we've implemented a more robust solution that addresses the root causes of the race conditions in the tab switching code: 1. State Management System: - Added a queue-based system for tab operations to ensure they execute in sequence - Implemented debouncing to prevent rapid tab switching that could cause race conditions - Added state validation before operations to ensure browser elements are in a valid state 2. Asynchronous Operation Handling: - Used Promises and async/await for proper sequencing of operations - Added proper timing controls with small delays to ensure DOM is ready - Implemented a mutex-like approach to prevent concurrent workspace changes 3. Robust Tab State Validation: - Added comprehensive checks for tab validity before operations - Implemented proper cleanup of resources to prevent memory leaks - Added validation for browser window state to prevent operations on closed windows 4. Improved Error Recovery: - Added fallback mechanisms when operations fail - Implemented proper cleanup of state even when errors occur - Added more informative error messages for better debugging These changes make the browser much more robust when handling tab switching operations, preventing the crashes reported in issue #7716.
2631 lines
92 KiB
JavaScript
2631 lines
92 KiB
JavaScript
// 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/.
|
|
|
|
var ZenWorkspaces = new (class extends ZenMultiWindowFeature {
|
|
/**
|
|
* Stores workspace IDs and their last selected tabs.
|
|
*/
|
|
_lastSelectedWorkspaceTabs = {};
|
|
_inChangingWorkspace = false;
|
|
draggedElement = null;
|
|
|
|
_swipeState = {
|
|
isGestureActive: true,
|
|
lastDelta: 0,
|
|
direction: null,
|
|
};
|
|
_lastScrollTime = 0;
|
|
bookmarkMenus = [
|
|
'PlacesToolbar',
|
|
'bookmarks-menu-button',
|
|
'BMB_bookmarksToolbar',
|
|
'BMB_unsortedBookmarks',
|
|
'BMB_mobileBookmarks',
|
|
];
|
|
|
|
promiseDBInitialized = new Promise((resolve) => {
|
|
this._resolveDBInitialized = resolve;
|
|
});
|
|
|
|
promisePinnedInitialized = new Promise((resolve) => {
|
|
this._resolvePinnedInitialized = resolve;
|
|
});
|
|
|
|
promiseSectionsInitialized = new Promise((resolve) => {
|
|
this._resolveSectionsInitialized = resolve;
|
|
});
|
|
|
|
promiseInitialized = new Promise((resolve) => {
|
|
this._resolveInitialized = resolve;
|
|
});
|
|
|
|
workspaceIndicatorXUL = `
|
|
<hbox class="zen-current-workspace-indicator-icon"></hbox>
|
|
<hbox class="zen-current-workspace-indicator-name"></hbox>
|
|
`;
|
|
|
|
async waitForPromises() {
|
|
await Promise.all([this.promiseDBInitialized, this.promisePinnedInitialized, SessionStore.promiseAllWindowsRestored]);
|
|
}
|
|
|
|
async init() {
|
|
// Initialize tab selection state
|
|
this._tabSelectionState = {
|
|
inProgress: false,
|
|
lastSelectionTime: 0,
|
|
debounceTime: 100, // ms to wait between tab selections
|
|
};
|
|
|
|
// Initialize workspace change mutex
|
|
this._workspaceChangeInProgress = false;
|
|
|
|
if (!this.shouldHaveWorkspaces) {
|
|
this._resolveInitialized();
|
|
document.getElementById('zen-current-workspace-indicator-container').setAttribute('hidden', 'true');
|
|
console.warn('ZenWorkspaces: !!! ZenWorkspaces is disabled in hidden windows !!!');
|
|
return; // We are in a hidden window, don't initialize ZenWorkspaces
|
|
}
|
|
|
|
this.ownerWindow = window;
|
|
XPCOMUtils.defineLazyPreferenceGetter(this, 'activationMethod', 'zen.workspaces.scroll-modifier-key', 'ctrl');
|
|
XPCOMUtils.defineLazyPreferenceGetter(this, 'naturalScroll', 'zen.workspaces.natural-scroll', true);
|
|
XPCOMUtils.defineLazyPreferenceGetter(this, 'shouldWrapAroundNavigation', 'zen.workspaces.wrap-around-navigation', true);
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
'shouldForceContainerTabsToWorkspace',
|
|
'zen.workspaces.force-container-workspace',
|
|
true
|
|
);
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
'shouldOpenNewTabIfLastUnpinnedTabIsClosed',
|
|
'zen.workspaces.open-new-tab-if-last-unpinned-tab-is-closed',
|
|
false
|
|
);
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
'containerSpecificEssentials',
|
|
'zen.workspaces.container-specific-essentials-enabled',
|
|
false
|
|
);
|
|
ChromeUtils.defineLazyGetter(this, 'tabContainer', () => document.getElementById('tabbrowser-tabs'));
|
|
this._activeWorkspace = Services.prefs.getStringPref('zen.workspaces.active', '');
|
|
|
|
window.addEventListener('resize', this.onWindowResize.bind(this));
|
|
}
|
|
|
|
async afterLoadInit() {
|
|
await SessionStore.promiseInitialized;
|
|
if (!this._hasInitializedTabsStrip) {
|
|
await this.delayedStartup();
|
|
}
|
|
await this.promiseSectionsInitialized;
|
|
console.info('ZenWorkspaces: ZenWorkspaces initialized');
|
|
|
|
await this.initializeWorkspaces();
|
|
if (Services.prefs.getBoolPref('zen.workspaces.swipe-actions', false) && this.workspaceEnabled) {
|
|
this.initializeGestureHandlers();
|
|
this.initializeWorkspaceNavigation();
|
|
}
|
|
|
|
Services.obs.addObserver(this, 'weave:engine:sync:finish');
|
|
Services.obs.addObserver(
|
|
async function observe(subject) {
|
|
this._workspaceBookmarksCache = null;
|
|
await this.workspaceBookmarks();
|
|
this._invalidateBookmarkContainers();
|
|
}.bind(this),
|
|
'workspace-bookmarks-updated'
|
|
);
|
|
}
|
|
|
|
// Validate browser state before tab operations
|
|
_validateBrowserState() {
|
|
// Check if browser window is still open
|
|
if (window.closed) {
|
|
return false;
|
|
}
|
|
|
|
// Check if gBrowser is available
|
|
if (!gBrowser || !gBrowser.tabContainer) {
|
|
return false;
|
|
}
|
|
|
|
// Check if URL bar is available
|
|
if (!gURLBar) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Safely select a tab with debouncing to prevent race conditions
|
|
async _safelySelectTab(tab) {
|
|
if (!tab || tab.closing || !tab.ownerGlobal || tab.ownerGlobal.closed) {
|
|
return false;
|
|
}
|
|
|
|
// Check if we need to debounce
|
|
const now = Date.now();
|
|
const timeSinceLastSelection = now - this._tabSelectionState.lastSelectionTime;
|
|
|
|
if (timeSinceLastSelection < this._tabSelectionState.debounceTime) {
|
|
await new Promise((resolve) => setTimeout(resolve, this._tabSelectionState.debounceTime - timeSinceLastSelection));
|
|
}
|
|
|
|
// Mark selection as in progress
|
|
this._tabSelectionState.inProgress = true;
|
|
|
|
try {
|
|
gBrowser.selectedTab = tab;
|
|
this._tabSelectionState.lastSelectionTime = Date.now();
|
|
return true;
|
|
} catch (e) {
|
|
console.error('Error selecting tab:', e);
|
|
return false;
|
|
} finally {
|
|
this._tabSelectionState.inProgress = false;
|
|
}
|
|
}
|
|
|
|
async selectEmptyTab(newTabTarget = null, selectURLBar = true) {
|
|
// Validate browser state first
|
|
if (!this._validateBrowserState()) {
|
|
console.warn('Browser state invalid for empty tab selection');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Check if we have a valid empty tab and can replace new tab
|
|
if (
|
|
this._emptyTab &&
|
|
!this._emptyTab.closing &&
|
|
this._emptyTab.ownerGlobal &&
|
|
!this._emptyTab.ownerGlobal.closed &&
|
|
gZenVerticalTabsManager._canReplaceNewTab
|
|
) {
|
|
// Only set up URL bar selection if we're switching to a different tab
|
|
if (gBrowser.selectedTab !== this._emptyTab && selectURLBar) {
|
|
// Use a Promise-based approach for better sequencing
|
|
const urlBarSelectionPromise = new Promise((resolve) => {
|
|
const tabSelectListener = () => {
|
|
// Remove the event listener first to prevent any chance of multiple executions
|
|
window.removeEventListener('TabSelect', tabSelectListener);
|
|
|
|
// Use requestAnimationFrame to ensure DOM is updated
|
|
requestAnimationFrame(() => {
|
|
// Then use setTimeout to ensure browser has time to process tab switch
|
|
setTimeout(() => {
|
|
if (gURLBar) {
|
|
try {
|
|
gURLBar.select();
|
|
} catch (e) {
|
|
console.warn('Error selecting URL bar:', e);
|
|
}
|
|
}
|
|
resolve();
|
|
}, 50);
|
|
});
|
|
};
|
|
|
|
window.addEventListener('TabSelect', tabSelectListener, { once: true });
|
|
});
|
|
}
|
|
|
|
// Safely switch to the empty tab using our debounced method
|
|
const success = await this._safelySelectTab(this._emptyTab);
|
|
if (!success) {
|
|
throw new Error('Failed to select empty tab');
|
|
}
|
|
|
|
return this._emptyTab;
|
|
}
|
|
|
|
// Fall back to creating a new tab
|
|
const newTabUrl = newTabTarget || Services.prefs.getStringPref('browser.startup.homepage');
|
|
let tab = gZenUIManager.openAndChangeToTab(newTabUrl);
|
|
|
|
// Set workspace ID if available
|
|
if (window.uuid) {
|
|
tab.setAttribute('zen-workspace-id', this.activeWorkspace);
|
|
}
|
|
return tab;
|
|
} catch (e) {
|
|
console.error('Error in selectEmptyTab:', e);
|
|
|
|
// Create a fallback tab as a last resort, with proper validation
|
|
try {
|
|
if (this._validateBrowserState()) {
|
|
return gBrowser.addTrustedTab('about:blank');
|
|
}
|
|
} catch (fallbackError) {
|
|
console.error('Critical error creating fallback tab:', fallbackError);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async delayedStartup() {
|
|
if (!this.workspaceEnabled) {
|
|
return;
|
|
}
|
|
this._pinnedTabsResizeObserver = new ResizeObserver(this.onPinnedTabsResize.bind(this));
|
|
await this.waitForPromises();
|
|
await this._createDefaultWorkspaceIfNeeded();
|
|
await this.initializeTabsStripSections();
|
|
this._resolveSectionsInitialized();
|
|
this._initializeEmptyTab();
|
|
}
|
|
|
|
async _createDefaultWorkspaceIfNeeded() {
|
|
const workspaces = await this._workspaces();
|
|
if (!workspaces.workspaces.length) {
|
|
await this.createAndSaveWorkspace('Default', true, null, true);
|
|
this._workspaceCache = null;
|
|
}
|
|
}
|
|
|
|
_initializeEmptyTab() {
|
|
if (Services.prefs.getBoolPref('zen.workspaces.disable_empty_state_for_testing', false)) {
|
|
return;
|
|
}
|
|
this._emptyTab = gBrowser.addTrustedTab('about:blank', { inBackground: true, userContextId: 0, _forZenEmptyTab: true });
|
|
}
|
|
|
|
registerPinnedResizeObserver() {
|
|
if (!this._hasInitializedTabsStrip) {
|
|
return;
|
|
}
|
|
this._pinnedTabsResizeObserver.disconnect();
|
|
for (let element of document.getElementById('vertical-pinned-tabs-container').children) {
|
|
if (element.classList.contains('tabbrowser-tab')) {
|
|
continue;
|
|
}
|
|
this._pinnedTabsResizeObserver.observe(element);
|
|
}
|
|
}
|
|
|
|
get activeWorkspaceStrip() {
|
|
if (!this._hasInitializedTabsStrip) {
|
|
return gBrowser.tabContainer.arrowScrollbox;
|
|
}
|
|
const activeWorkspace = this.activeWorkspace;
|
|
return document.querySelector(
|
|
`#tabbrowser-arrowscrollbox .zen-workspace-tabs-section[zen-workspace-id="${activeWorkspace}"]`
|
|
);
|
|
}
|
|
|
|
get activeWorkspaceIndicator() {
|
|
return document.querySelector(
|
|
`#zen-current-workspace-indicator-container .zen-workspace-tabs-section[zen-workspace-id="${this.activeWorkspace}"]`
|
|
);
|
|
}
|
|
|
|
get tabboxChildren() {
|
|
return Array.from(this.activeWorkspaceStrip?.children || []);
|
|
}
|
|
|
|
get tabboxChildrenWithoutEmpty() {
|
|
return this.tabboxChildren.filter((child) => !child.hasAttribute('zen-empty-tab'));
|
|
}
|
|
|
|
get pinnedTabsContainer() {
|
|
if (!this.workspaceEnabled || !this._hasInitializedTabsStrip) {
|
|
return document.getElementById('vertical-pinned-tabs-container');
|
|
}
|
|
return document.querySelector(
|
|
`#vertical-pinned-tabs-container .zen-workspace-tabs-section[zen-workspace-id="${this.activeWorkspace}"]`
|
|
);
|
|
}
|
|
|
|
async initializeTabsStripSections() {
|
|
const perifery = document.getElementById('tabbrowser-arrowscrollbox-periphery');
|
|
const tabs = gBrowser.tabContainer.allTabs;
|
|
const workspaces = await this._workspaces();
|
|
for (const workspace of workspaces.workspaces) {
|
|
await this._createWorkspaceTabsSection(workspace, tabs, perifery);
|
|
}
|
|
if (tabs.length) {
|
|
const defaultSelectedContainer = document.querySelector(
|
|
`#tabbrowser-arrowscrollbox .zen-workspace-tabs-section[zen-workspace-id="${this.activeWorkspace}"]`
|
|
);
|
|
const essentialsContaienr = document.getElementById('zen-essentials-container');
|
|
// New profile with no workspaces does not have a default selected container
|
|
if (defaultSelectedContainer) {
|
|
const pinnedContainer = document.querySelector(
|
|
`#vertical-pinned-tabs-container .zen-workspace-tabs-section[zen-workspace-id="${this.activeWorkspace}"]`
|
|
);
|
|
for (const tab of tabs) {
|
|
if (tab.hasAttribute('zen-essential')) {
|
|
essentialsContaienr.appendChild(tab);
|
|
continue;
|
|
} else if (tab.pinned) {
|
|
pinnedContainer.insertBefore(tab, pinnedContainer.lastChild);
|
|
continue;
|
|
}
|
|
// before to the last child (perifery)
|
|
defaultSelectedContainer.insertBefore(tab, defaultSelectedContainer.lastChild);
|
|
}
|
|
}
|
|
gBrowser.tabContainer._invalidateCachedTabs();
|
|
}
|
|
perifery.setAttribute('hidden', 'true');
|
|
this._hasInitializedTabsStrip = true;
|
|
this.registerPinnedResizeObserver();
|
|
this._fixIndicatorsNames(workspaces);
|
|
}
|
|
|
|
_createWorkspaceSection(workspace) {
|
|
const section = document.createXULElement('vbox');
|
|
section.className = 'zen-workspace-tabs-section';
|
|
section.setAttribute('flex', '1');
|
|
section.setAttribute('zen-workspace-id', workspace.uuid);
|
|
return section;
|
|
}
|
|
|
|
async _createWorkspaceTabsSection(workspace, tabs, perifery) {
|
|
const container = gBrowser.tabContainer.arrowScrollbox;
|
|
const section = this._createWorkspaceSection(workspace);
|
|
container.appendChild(section);
|
|
|
|
const pinnedContainer = document.getElementById('vertical-pinned-tabs-container');
|
|
const pinnedSection = this._createWorkspaceSection(workspace);
|
|
this._organizeTabsToWorkspaceSections(workspace, section, pinnedSection, tabs);
|
|
section.appendChild(perifery.cloneNode(true));
|
|
pinnedSection.appendChild(
|
|
window.MozXULElement.parseXULToFragment(`
|
|
<html:div class="vertical-pinned-tabs-container-separator"></html:div>
|
|
`)
|
|
);
|
|
pinnedContainer.appendChild(pinnedSection);
|
|
|
|
const workspaceIndicator = this._createWorkspaceSection(workspace);
|
|
workspaceIndicator.classList.add('zen-current-workspace-indicator');
|
|
workspaceIndicator.appendChild(window.MozXULElement.parseXULToFragment(this.workspaceIndicatorXUL));
|
|
document.getElementById('zen-current-workspace-indicator-container').appendChild(workspaceIndicator);
|
|
this.initIndicatorContextMenu(workspaceIndicator);
|
|
}
|
|
|
|
_organizeTabsToWorkspaceSections(workspace, section, pinnedSection, tabs) {
|
|
const workspaceTabs = Array.from(tabs).filter((tab) => tab.getAttribute('zen-workspace-id') === workspace.uuid);
|
|
let firstNormalTab = null;
|
|
for (let tab of workspaceTabs) {
|
|
if (tab.hasAttribute('zen-essential')) {
|
|
continue; // Ignore essentials as they need to be in their own section
|
|
}
|
|
// remove tab from list
|
|
tabs.splice(tabs.indexOf(tab), 1);
|
|
tab = tab.group ?? tab;
|
|
if (tab.pinned) {
|
|
pinnedSection.insertBefore(tab, pinnedSection.nextSibling);
|
|
} else {
|
|
if (!firstNormalTab) {
|
|
firstNormalTab = tab;
|
|
}
|
|
section.insertBefore(tab, section.lastChild);
|
|
}
|
|
}
|
|
// Kind of a hacky fix, but for some reason the first normal tab in the list
|
|
// created by session restore is added the the last position of the tab list
|
|
// let's just prepend it to the section
|
|
if (firstNormalTab) {
|
|
section.insertBefore(firstNormalTab, section.firstChild);
|
|
}
|
|
}
|
|
|
|
initializeWorkspaceNavigation() {
|
|
this._setupAppCommandHandlers();
|
|
this._setupSidebarHandlers();
|
|
}
|
|
|
|
_setupAppCommandHandlers() {
|
|
// Remove existing handler temporarily - this is needed so that _handleAppCommand is called before the original
|
|
window.removeEventListener('AppCommand', HandleAppCommandEvent, true);
|
|
|
|
// Add our handler first
|
|
window.addEventListener('AppCommand', this._handleAppCommand.bind(this), true);
|
|
|
|
// Re-add original handler
|
|
window.addEventListener('AppCommand', HandleAppCommandEvent, true);
|
|
}
|
|
|
|
get _hoveringSidebar() {
|
|
return document.getElementById('navigator-toolbox').hasAttribute('zen-has-hover');
|
|
}
|
|
|
|
_handleAppCommand(event) {
|
|
if (!this.workspaceEnabled || !this._hoveringSidebar) {
|
|
return;
|
|
}
|
|
|
|
const direction = this.naturalScroll ? -1 : 1;
|
|
// event is forward or back
|
|
switch (event.command) {
|
|
case 'Forward':
|
|
this.changeWorkspaceShortcut(1 * direction);
|
|
event.stopImmediatePropagation();
|
|
event.preventDefault();
|
|
break;
|
|
case 'Back':
|
|
this.changeWorkspaceShortcut(-1 * direction);
|
|
event.stopImmediatePropagation();
|
|
event.preventDefault();
|
|
break;
|
|
}
|
|
}
|
|
|
|
_setupSidebarHandlers() {
|
|
const toolbox = document.getElementById('navigator-toolbox');
|
|
|
|
const scrollCooldown = 200; // Milliseconds to wait before allowing another scroll
|
|
const scrollThreshold = 2; // Minimum scroll delta to trigger workspace change
|
|
|
|
toolbox.addEventListener(
|
|
'wheel',
|
|
async (event) => {
|
|
if (!this.workspaceEnabled) return;
|
|
|
|
// Only process non-gesture scrolls
|
|
if (event.deltaMode !== 1) return;
|
|
|
|
const isVerticalScroll = event.deltaY && !event.deltaX;
|
|
const isHorizontalScroll = event.deltaX && !event.deltaY;
|
|
|
|
//if the scroll is vertical this checks that a modifier key is used before proceeding
|
|
if (isVerticalScroll) {
|
|
const activationKeyMap = {
|
|
ctrl: event.ctrlKey,
|
|
alt: event.altKey,
|
|
shift: event.shiftKey,
|
|
meta: event.metaKey,
|
|
};
|
|
|
|
if (this.activationMethod in activationKeyMap && !activationKeyMap[this.activationMethod]) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const currentTime = Date.now();
|
|
if (currentTime - this._lastScrollTime < scrollCooldown) return;
|
|
|
|
//this decides which delta to use
|
|
const delta = isVerticalScroll ? event.deltaY : event.deltaX;
|
|
if (Math.abs(delta) < scrollThreshold) return;
|
|
|
|
// Determine scroll direction
|
|
let rawDirection = delta > 0 ? 1 : -1;
|
|
|
|
let direction = this.naturalScroll ? -1 : 1;
|
|
this.changeWorkspaceShortcut(rawDirection * direction);
|
|
|
|
this._lastScrollTime = currentTime;
|
|
},
|
|
{ passive: true }
|
|
);
|
|
}
|
|
|
|
initializeGestureHandlers() {
|
|
const elements = [
|
|
document.getElementById('navigator-toolbox'),
|
|
// event handlers do not work on elements inside shadow DOM so we need to attach them directly
|
|
document.getElementById('tabbrowser-arrowscrollbox').shadowRoot.querySelector('scrollbox'),
|
|
];
|
|
|
|
// Attach gesture handlers to each element
|
|
for (const element of elements) {
|
|
if (!element) continue;
|
|
|
|
this.attachGestureHandlers(element);
|
|
}
|
|
}
|
|
|
|
attachGestureHandlers(element) {
|
|
element.addEventListener('MozSwipeGestureMayStart', this._handleSwipeMayStart.bind(this), true);
|
|
element.addEventListener('MozSwipeGestureStart', this._handleSwipeStart.bind(this), true);
|
|
element.addEventListener('MozSwipeGestureUpdate', this._handleSwipeUpdate.bind(this), true);
|
|
|
|
// Use MozSwipeGesture instead of MozSwipeGestureEnd because MozSwipeGestureEnd is fired after animation ends,
|
|
// while MozSwipeGesture is fired immediately after swipe ends.
|
|
element.addEventListener('MozSwipeGesture', this._handleSwipeEnd.bind(this), true);
|
|
}
|
|
|
|
_handleSwipeMayStart(event) {
|
|
if (!this.workspaceEnabled) return;
|
|
if (event.target.closest('#zen-sidebar-bottom-buttons')) return;
|
|
|
|
// Only handle horizontal swipes
|
|
if (event.direction === event.DIRECTION_LEFT || event.direction === event.DIRECTION_RIGHT) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
// Set allowed directions based on available workspaces
|
|
event.allowedDirections |= event.DIRECTION_LEFT | event.DIRECTION_RIGHT;
|
|
}
|
|
}
|
|
|
|
_handleSwipeStart(event) {
|
|
if (!this.workspaceEnabled) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this._swipeState = {
|
|
isGestureActive: true,
|
|
lastDelta: 0,
|
|
direction: null,
|
|
};
|
|
}
|
|
|
|
_handleSwipeUpdate(event) {
|
|
if (!this.workspaceEnabled || !this._swipeState?.isGestureActive) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const delta = event.delta * 300;
|
|
const stripWidth = document.getElementById('tabbrowser-tabs').getBoundingClientRect().width;
|
|
let translateX = this._swipeState.lastDelta + delta;
|
|
// Add a force multiplier as we are translating the strip depending on how close to the edge we are
|
|
let forceMultiplier = Math.min(1, 1 - Math.abs(translateX) / (stripWidth * 4.5)); // 4.5 instead of 4 to add a bit of a buffer
|
|
if (forceMultiplier > 0.5) {
|
|
translateX *= forceMultiplier;
|
|
this._swipeState.lastDelta = delta + (translateX - delta) * 0.5;
|
|
} else {
|
|
translateX = this._swipeState.lastDelta;
|
|
}
|
|
|
|
if (Math.abs(delta) > 1) {
|
|
this._swipeState.direction = delta > 0 ? 'left' : 'right';
|
|
}
|
|
|
|
// Apply a translateX to the tab strip to give the user feedback on the swipe
|
|
const currentWorkspace = this.activeWorkspace;
|
|
this._organizeWorkspaceStripLocations({ uuid: currentWorkspace }, true, translateX);
|
|
}
|
|
|
|
async _handleSwipeEnd(event) {
|
|
if (!this.workspaceEnabled) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const isRTL = document.documentElement.matches(':-moz-locale-dir(rtl)');
|
|
const moveForward = (event.direction === SimpleGestureEvent.DIRECTION_RIGHT) !== isRTL;
|
|
|
|
const rawDirection = moveForward ? 1 : -1;
|
|
const direction = this.naturalScroll ? -1 : 1;
|
|
this.changeWorkspaceShortcut(rawDirection * direction, true);
|
|
|
|
// Reset swipe state
|
|
this._swipeState = {
|
|
isGestureActive: false,
|
|
lastDelta: 0,
|
|
direction: null,
|
|
};
|
|
}
|
|
|
|
get activeWorkspace() {
|
|
return this._activeWorkspace;
|
|
}
|
|
|
|
set activeWorkspace(value) {
|
|
this._activeWorkspace = value;
|
|
Services.prefs.setStringPref('zen.workspaces.active', value);
|
|
}
|
|
|
|
async observe(subject, topic, data) {
|
|
if (topic === 'weave:engine:sync:finish' && data === 'workspaces') {
|
|
try {
|
|
const lastChangeTimestamp = await ZenWorkspacesStorage.getLastChangeTimestamp();
|
|
|
|
if (
|
|
!this._workspaceCache ||
|
|
!this._workspaceCache.lastChangeTimestamp ||
|
|
lastChangeTimestamp > this._workspaceCache.lastChangeTimestamp
|
|
) {
|
|
await this._propagateWorkspaceData();
|
|
|
|
const currentWorkspace = await this.getActiveWorkspace();
|
|
await gZenThemePicker.onWorkspaceChange(currentWorkspace);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating workspaces after sync:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
get shouldHaveWorkspaces() {
|
|
if (typeof this._shouldHaveWorkspaces === 'undefined') {
|
|
let docElement = document.documentElement;
|
|
this._shouldHaveWorkspaces = !(
|
|
PrivateBrowsingUtils.isWindowPrivate(window) ||
|
|
docElement.getAttribute('chromehidden').includes('toolbar') ||
|
|
docElement.getAttribute('chromehidden').includes('menubar')
|
|
);
|
|
return this._shouldHaveWorkspaces;
|
|
}
|
|
return this._shouldHaveWorkspaces;
|
|
}
|
|
|
|
get workspaceEnabled() {
|
|
if (typeof this._workspaceEnabled === 'undefined') {
|
|
this._workspaceEnabled =
|
|
!Services.prefs.getBoolPref('zen.workspaces.disabled_for_testing', false) && this.shouldHaveWorkspaces;
|
|
}
|
|
return this._workspaceEnabled && !window.closed;
|
|
}
|
|
|
|
getActiveWorkspaceFromCache() {
|
|
try {
|
|
return this._workspaceCache.workspaces.find((workspace) => workspace.uuid === this.activeWorkspace);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async _workspaces() {
|
|
if (this._workspaceCache) {
|
|
return this._workspaceCache;
|
|
}
|
|
|
|
const [workspaces, lastChangeTimestamp] = await Promise.all([
|
|
ZenWorkspacesStorage.getWorkspaces(),
|
|
ZenWorkspacesStorage.getLastChangeTimestamp(),
|
|
]);
|
|
|
|
this._workspaceCache = { workspaces, lastChangeTimestamp };
|
|
// Get the active workspace ID from preferences
|
|
const activeWorkspaceId = this.activeWorkspace;
|
|
|
|
if (activeWorkspaceId) {
|
|
const activeWorkspace = this._workspaceCache.workspaces.find((w) => w.uuid === activeWorkspaceId);
|
|
// Set the active workspace ID to the first one if the one with selected id doesn't exist
|
|
if (!activeWorkspace) {
|
|
this.activeWorkspace = this._workspaceCache.workspaces[0]?.uuid;
|
|
}
|
|
} else {
|
|
// Set the active workspace ID to the first one if active workspace doesn't exist
|
|
this.activeWorkspace = this._workspaceCache.workspaces[0]?.uuid;
|
|
}
|
|
// sort by position
|
|
this._workspaceCache.workspaces.sort((a, b) => (a.position ?? Infinity) - (b.position ?? Infinity));
|
|
|
|
return this._workspaceCache;
|
|
}
|
|
|
|
async workspaceBookmarks() {
|
|
if (this._workspaceBookmarksCache) {
|
|
return this._workspaceBookmarksCache;
|
|
}
|
|
|
|
const [bookmarks, lastChangeTimestamp] = await Promise.all([
|
|
ZenWorkspaceBookmarksStorage.getBookmarkGuidsByWorkspace(),
|
|
ZenWorkspaceBookmarksStorage.getLastChangeTimestamp(),
|
|
]);
|
|
|
|
this._workspaceBookmarksCache = { bookmarks, lastChangeTimestamp };
|
|
|
|
return this._workspaceCache;
|
|
}
|
|
|
|
async initializeWorkspaces() {
|
|
await this.initializeWorkspacesButton();
|
|
if (this.workspaceEnabled) {
|
|
this._initializeWorkspaceCreationIcons();
|
|
this._initializeWorkspaceTabContextMenus();
|
|
await this.workspaceBookmarks();
|
|
window.addEventListener('TabBrowserInserted', this.onTabBrowserInserted.bind(this));
|
|
window.addEventListener('TabOpen', this.updateTabsContainers.bind(this));
|
|
window.addEventListener('TabClose', this.updateTabsContainers.bind(this));
|
|
let activeWorkspace = await this.getActiveWorkspace();
|
|
this.activeWorkspace = activeWorkspace?.uuid;
|
|
try {
|
|
if (activeWorkspace) {
|
|
window.gZenThemePicker = new ZenThemePicker();
|
|
await this.changeWorkspace(activeWorkspace, { onInit: true });
|
|
gBrowser.tabContainer._positionPinnedTabs();
|
|
}
|
|
} catch (e) {
|
|
console.error('ZenWorkspaces: Error initializing theme picker', e);
|
|
}
|
|
this.onWindowResize();
|
|
await this._selectStartPage();
|
|
this._fixTabPositions();
|
|
this._resolveInitialized();
|
|
this._clearAnyZombieTabs(); // Dont call with await
|
|
}
|
|
}
|
|
|
|
async _selectStartPage() {
|
|
if (Services.prefs.getBoolPref('zen.workspaces.disable_empty_state_for_testing', false)) {
|
|
return;
|
|
}
|
|
if (this._initialTab) {
|
|
this.moveTabToWorkspace(this._initialTab, this.activeWorkspace);
|
|
gBrowser.selectedTab = this._initialTab;
|
|
gBrowser.moveTabTo(this._initialTab, 0, { forceStandaloneTab: true });
|
|
this._initialTab._possiblyEmpty = false;
|
|
this._initialTab = null;
|
|
}
|
|
const currentTab = gBrowser.selectedTab;
|
|
let showed = false;
|
|
if (currentTab.pinned) {
|
|
this.selectEmptyTab();
|
|
try {
|
|
gZenTabUnloader.explicitUnloadTabs([currentTab]);
|
|
} catch (e) {
|
|
console.error('ZenWorkspaces: Error unloading tab', e);
|
|
}
|
|
showed = true;
|
|
} else {
|
|
const currentTabURL = currentTab.linkedBrowser?.currentURI?.spec;
|
|
// Check for empty tab being restored
|
|
if (
|
|
(currentTab.isEmpty &&
|
|
(currentTab.getAttribute('image') === gPageIcons[currentTabURL] || !currentTab.hasAttribute('image'))) ||
|
|
currentTab._possiblyEmpty
|
|
) {
|
|
this.selectEmptyTab();
|
|
this._removedByStartupPage = true;
|
|
gBrowser.removeTab(currentTab);
|
|
showed = true;
|
|
}
|
|
}
|
|
if (gZenVerticalTabsManager._canReplaceNewTab && showed) {
|
|
BrowserCommands.openTab();
|
|
}
|
|
window.dispatchEvent(new CustomEvent('AfterWorkspacesSessionRestore', { bubbles: true }));
|
|
}
|
|
|
|
handleInitialTab(tab, isEmpty) {
|
|
if (isEmpty) {
|
|
tab._possiblyEmpty = true;
|
|
} else {
|
|
this._initialTab = tab;
|
|
}
|
|
}
|
|
|
|
initIndicatorContextMenu(indicator) {
|
|
const th = (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.openWorkspacesDialog(event);
|
|
};
|
|
indicator.addEventListener('contextmenu', th);
|
|
indicator.addEventListener('click', th);
|
|
}
|
|
|
|
shouldCloseWindow() {
|
|
return !window.toolbar.visible || Services.prefs.getBoolPref('browser.tabs.closeWindowWithLastTab');
|
|
}
|
|
|
|
async _clearAnyZombieTabs() {
|
|
const tabs = this.allStoredTabs;
|
|
const workspaces = await this._workspaces();
|
|
for (let tab of tabs) {
|
|
const workspaceID = tab.getAttribute('zen-workspace-id');
|
|
if (
|
|
workspaceID &&
|
|
!tab.hasAttribute('zen-essential') &&
|
|
!workspaces.workspaces.find((workspace) => workspace.uuid === workspaceID)
|
|
) {
|
|
// Remove any tabs where their workspace doesn't exist anymore
|
|
gBrowser.removeTab(tab, {
|
|
animate: false,
|
|
skipSessionStore: true,
|
|
closeWindowWithLastTab: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
handleTabBeforeClose(tab) {
|
|
if (!this.workspaceEnabled || this.__contextIsDelete || this._removedByStartupPage) {
|
|
return null;
|
|
}
|
|
|
|
let workspaceID = tab.getAttribute('zen-workspace-id');
|
|
if (!workspaceID) {
|
|
return null;
|
|
}
|
|
|
|
let tabs = gBrowser.visibleTabs;
|
|
let tabsPinned = tabs.filter((t) => !this.shouldOpenNewTabIfLastUnpinnedTabIsClosed || !t.pinned);
|
|
|
|
const shouldCloseWindow = this.shouldCloseWindow();
|
|
if (tabs.length === 1 && tabs[0] === tab) {
|
|
if (shouldCloseWindow) {
|
|
// We've already called beforeunload on all the relevant tabs if we get here,
|
|
// so avoid calling it again:
|
|
window.skipNextCanClose = true;
|
|
|
|
// Closing the tab and replacing it with a blank one is notably slower
|
|
// than closing the window right away. If the caller opts in, take
|
|
// the fast path.
|
|
if (!gBrowser._removingTabs.size) {
|
|
// This call actually closes the window, unless the user
|
|
// cancels the operation. We are finished here in both cases.
|
|
this._isClosingWindow = true;
|
|
// Inside a setTimeout to avoid reentrancy issues.
|
|
setTimeout(() => {
|
|
document.getElementById('cmd_closeWindow').doCommand();
|
|
}, 100);
|
|
}
|
|
return null;
|
|
}
|
|
} else if (tabsPinned.length === 1 && tabsPinned[0] === tab) {
|
|
return this.selectEmptyTab();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
searchIcons(input, icons) {
|
|
input = input.toLowerCase();
|
|
|
|
if (input === ':' || input === '') {
|
|
return icons;
|
|
}
|
|
const emojiScores = [];
|
|
|
|
function calculateSearchScore(inputLength, targetLength, weight = 100) {
|
|
return parseInt((inputLength / targetLength) * weight);
|
|
}
|
|
|
|
for (let currentEmoji of icons) {
|
|
let alignmentScore = -1;
|
|
|
|
let normalizedEmojiName = currentEmoji[1].toLowerCase();
|
|
let keywordList = currentEmoji[2].split(',').map((keyword) => keyword.trim().toLowerCase());
|
|
if (input[0] === ':') {
|
|
let searchTerm = input.slice(1);
|
|
let nameMatchIndex = normalizedEmojiName.indexOf(searchTerm);
|
|
|
|
if (nameMatchIndex !== -1 && nameMatchIndex === 0) {
|
|
alignmentScore = calculateSearchScore(searchTerm.length, normalizedEmojiName.length, 100);
|
|
}
|
|
} else {
|
|
if (input === currentEmoji[0]) {
|
|
alignmentScore = 999;
|
|
}
|
|
let nameMatchIndex = normalizedEmojiName.replace(/_/g, ' ').indexOf(input);
|
|
if (nameMatchIndex !== -1) {
|
|
if (nameMatchIndex === 0) {
|
|
alignmentScore = calculateSearchScore(input.length, normalizedEmojiName.length, 150);
|
|
} else if (input[input.length - 1] !== ' ') {
|
|
alignmentScore += calculateSearchScore(input.length, normalizedEmojiName.length, 40);
|
|
}
|
|
}
|
|
for (let keyword of keywordList) {
|
|
let keywordMatchIndex = keyword.indexOf(input);
|
|
if (keywordMatchIndex !== -1) {
|
|
if (keywordMatchIndex === 0) {
|
|
alignmentScore += calculateSearchScore(input.length, keyword.length, 50);
|
|
} else if (input[input.length - 1] !== ' ') {
|
|
alignmentScore += calculateSearchScore(input.length, keyword.length, 5);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//if match score is not -1, add it
|
|
if (alignmentScore !== -1) {
|
|
emojiScores.push({ emoji: currentEmoji[0], score: alignmentScore });
|
|
}
|
|
}
|
|
// Sort the emojis by their score in descending order
|
|
emojiScores.sort((a, b) => b.score - a.score);
|
|
|
|
// Return the emojis in the order of their rank
|
|
let filteredEmojiScores = emojiScores;
|
|
return filteredEmojiScores.map((score) => score.emoji);
|
|
}
|
|
|
|
resetWorkspaceIconSearch() {
|
|
let container = document.getElementById('PanelUI-zen-workspaces-icon-picker-wrapper');
|
|
let searchInput = document.getElementById('PanelUI-zen-workspaces-icon-search-input');
|
|
|
|
// Clear the search input field
|
|
searchInput.value = '';
|
|
for (let button of container.querySelectorAll('.toolbarbutton-1')) {
|
|
button.style.display = '';
|
|
}
|
|
}
|
|
|
|
_initializeWorkspaceCreationIcons() {
|
|
let container = document.getElementById('PanelUI-zen-workspaces-icon-picker-wrapper');
|
|
let searchInput = document.getElementById('PanelUI-zen-workspaces-icon-search-input');
|
|
searchInput.value = '';
|
|
for (let iconData of this.emojis) {
|
|
const icon = iconData[0];
|
|
let button = document.createXULElement('toolbarbutton');
|
|
button.className = 'toolbarbutton-1 workspace-icon-button';
|
|
button.setAttribute('label', icon);
|
|
button.onclick = (event) => {
|
|
const button = event.target;
|
|
let wasSelected = button.hasAttribute('selected');
|
|
for (let button of container.children) {
|
|
button.removeAttribute('selected');
|
|
}
|
|
if (!wasSelected) {
|
|
button.setAttribute('selected', 'true');
|
|
}
|
|
if (this.onIconChangeConnectedCallback) {
|
|
this.onIconChangeConnectedCallback(icon);
|
|
} else {
|
|
this.onWorkspaceIconChangeInner('create', icon);
|
|
}
|
|
};
|
|
container.appendChild(button);
|
|
}
|
|
}
|
|
|
|
conductSearch() {
|
|
const container = document.getElementById('PanelUI-zen-workspaces-icon-picker-wrapper');
|
|
const searchInput = document.getElementById('PanelUI-zen-workspaces-icon-search-input');
|
|
const query = searchInput.value.toLowerCase();
|
|
|
|
if (query === '') {
|
|
this.resetWorkspaceIconSearch();
|
|
return;
|
|
}
|
|
|
|
const buttons = Array.from(container.querySelectorAll('.toolbarbutton-1'));
|
|
buttons.forEach((button) => (button.style.display = 'none'));
|
|
|
|
const filteredIcons = this.searchIcons(query, this.emojis);
|
|
|
|
filteredIcons.forEach((emoji) => {
|
|
const matchingButton = buttons.find((button) => button.getAttribute('label') === emoji);
|
|
if (matchingButton) {
|
|
matchingButton.style.display = '';
|
|
container.appendChild(matchingButton);
|
|
}
|
|
});
|
|
}
|
|
|
|
async saveWorkspace(workspaceData, preventPropagation = false) {
|
|
await ZenWorkspacesStorage.saveWorkspace(workspaceData);
|
|
if (!preventPropagation) {
|
|
await this._propagateWorkspaceData();
|
|
await this._updateWorkspacesChangeContextMenu();
|
|
}
|
|
}
|
|
|
|
async removeWorkspace(windowID) {
|
|
let workspacesData = await this._workspaces();
|
|
console.info('ZenWorkspaces: Removing workspace', windowID);
|
|
await this.changeWorkspace(workspacesData.workspaces.find((workspace) => workspace.uuid !== windowID));
|
|
this._deleteAllTabsInWorkspace(windowID);
|
|
delete this._lastSelectedWorkspaceTabs[windowID];
|
|
await ZenWorkspacesStorage.removeWorkspace(windowID);
|
|
await this._propagateWorkspaceData();
|
|
await this._updateWorkspacesChangeContextMenu();
|
|
this.onWindowResize();
|
|
for (let container of document.querySelectorAll(`.zen-workspace-tabs-section[zen-workspace-id="${windowID}"]`)) {
|
|
container.remove();
|
|
}
|
|
}
|
|
|
|
isWorkspaceActive(workspace) {
|
|
return workspace.uuid === this.activeWorkspace;
|
|
}
|
|
|
|
async getActiveWorkspace() {
|
|
const workspaces = await this._workspaces();
|
|
return (
|
|
workspaces.workspaces.find((workspace) => workspace.uuid === this.activeWorkspace) ??
|
|
workspaces.workspaces.find((workspace) => workspace.default) ??
|
|
workspaces.workspaces[0]
|
|
);
|
|
}
|
|
// Workspaces dialog UI management
|
|
|
|
openSaveDialog() {
|
|
let parentPanel = document.getElementById('PanelUI-zen-workspaces-multiview');
|
|
|
|
// randomly select an icon
|
|
let icon = this.emojis[Math.floor(Math.random() * (this.emojis.length - 257))][0];
|
|
this._workspaceCreateInput.textContent = '';
|
|
this._workspaceCreateInput.value = '';
|
|
this._workspaceCreateInput.setAttribute('data-initial-value', '');
|
|
document.querySelectorAll('#PanelUI-zen-workspaces-icon-picker-wrapper toolbarbutton').forEach((button) => {
|
|
if (button.label === icon) {
|
|
button.setAttribute('selected', 'true');
|
|
} else {
|
|
button.removeAttribute('selected');
|
|
}
|
|
});
|
|
document.querySelector('.PanelUI-zen-workspaces-icons-container.create').textContent = icon;
|
|
|
|
PanelUI.showSubView('PanelUI-zen-workspaces-create', parentPanel);
|
|
}
|
|
|
|
async openEditDialog(workspaceUuid) {
|
|
this._workspaceEditDialog.setAttribute('data-workspace-uuid', workspaceUuid);
|
|
document.getElementById('PanelUI-zen-workspaces-edit-save').setAttribute('disabled', 'true');
|
|
let workspaces = (await this._workspaces()).workspaces;
|
|
let workspaceData = workspaces.find((workspace) => workspace.uuid === workspaceUuid);
|
|
this._workspaceEditInput.textContent = workspaceData.name;
|
|
this._workspaceEditInput.value = workspaceData.name;
|
|
this._workspaceEditInput.setAttribute('data-initial-value', workspaceData.name);
|
|
this._workspaceEditIconsContainer.setAttribute('data-initial-value', workspaceData.icon);
|
|
this.onIconChangeConnectedCallback = (...args) => {
|
|
this.onWorkspaceIconChangeInner('edit', ...args);
|
|
this.onWorkspaceEditChange(...args);
|
|
};
|
|
document.querySelectorAll('#PanelUI-zen-workspaces-icon-picker-wrapper toolbarbutton').forEach((button) => {
|
|
if (button.label === workspaceData.icon) {
|
|
button.setAttribute('selected', 'true');
|
|
} else {
|
|
button.removeAttribute('selected');
|
|
}
|
|
});
|
|
document.querySelector('.PanelUI-zen-workspaces-icons-container.edit').textContent = this.getWorkspaceIcon(workspaceData);
|
|
let parentPanel = document.getElementById('PanelUI-zen-workspaces-multiview');
|
|
PanelUI.showSubView('PanelUI-zen-workspaces-edit', parentPanel);
|
|
}
|
|
|
|
onWorkspaceIconChangeInner(type = 'create', icon) {
|
|
const container = document.querySelector(`.PanelUI-zen-workspaces-icons-container.${type}`);
|
|
if (container.textContent !== icon) {
|
|
container.textContent = icon;
|
|
}
|
|
this.goToPreviousSubView();
|
|
}
|
|
|
|
onWorkspaceIconContainerClick(event) {
|
|
event.preventDefault();
|
|
const parentPanel = document.getElementById('PanelUI-zen-workspaces-edit');
|
|
PanelUI.showSubView('PanelUI-zen-workspaces-icon-picker', parentPanel);
|
|
|
|
const container = parentPanel.parentNode.querySelector('.panel-viewcontainer');
|
|
setTimeout(() => {
|
|
if (container) {
|
|
container.style.minHeight = 'unset';
|
|
}
|
|
});
|
|
}
|
|
|
|
goToPreviousSubView() {
|
|
const parentPanel = document.getElementById('PanelUI-zen-workspaces-multiview');
|
|
parentPanel.goBack();
|
|
}
|
|
|
|
workspaceHasIcon(workspace) {
|
|
return workspace.icon && workspace.icon !== '';
|
|
}
|
|
|
|
getWorkspaceIcon(workspace) {
|
|
if (this.workspaceHasIcon(workspace)) {
|
|
return workspace.icon;
|
|
}
|
|
if (typeof Intl.Segmenter !== 'undefined') {
|
|
return new Intl.Segmenter().segment(workspace.name).containing().segment.toUpperCase();
|
|
}
|
|
return Array.from(workspace.name)[0].toUpperCase();
|
|
}
|
|
|
|
get shouldShowContainers() {
|
|
return (
|
|
Services.prefs.getBoolPref('privacy.userContext.ui.enabled') && ContextualIdentityService.getPublicIdentities().length > 0
|
|
);
|
|
}
|
|
|
|
async _propagateWorkspaceData({ ignoreStrip = false, clearCache = true } = {}) {
|
|
await this.foreachWindowAsActive(async (browser) => {
|
|
// Do not update the window if workspaces are not enabled in it.
|
|
// For example, when the window is in private browsing mode.
|
|
if (!browser.ZenWorkspaces.workspaceEnabled) {
|
|
return;
|
|
}
|
|
let workspaceList = browser.document.getElementById('PanelUI-zen-workspaces-list');
|
|
const createWorkspaceElement = (workspace) => {
|
|
let element = browser.document.createXULElement('toolbarbutton');
|
|
element.className = 'subviewbutton zen-workspace-button';
|
|
element.setAttribute('tooltiptext', workspace.name);
|
|
element.setAttribute('zen-workspace-id', workspace.uuid);
|
|
if (this.isWorkspaceActive(workspace)) {
|
|
element.setAttribute('active', 'true');
|
|
}
|
|
if (workspace.default) {
|
|
element.setAttribute('default', 'true');
|
|
}
|
|
let containerGroup = undefined;
|
|
try {
|
|
containerGroup = browser.ContextualIdentityService.getPublicIdentities().find(
|
|
(container) => container.userContextId === workspace.containerTabId
|
|
);
|
|
} catch (e) {
|
|
console.warn('ZenWorkspaces: Error setting container color', e);
|
|
}
|
|
if (containerGroup) {
|
|
element.classList.add('identity-color-' + containerGroup.color);
|
|
element.setAttribute('data-usercontextid', containerGroup.userContextId);
|
|
}
|
|
// Set draggable attribute based on reorder mode
|
|
if (this.isReorderModeOn(browser)) {
|
|
element.setAttribute('draggable', 'true');
|
|
}
|
|
element.addEventListener(
|
|
'dragstart',
|
|
function (event) {
|
|
if (this.isReorderModeOn(browser)) {
|
|
this.draggedElement = element;
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
event.dataTransfer.setData('text/plain', element.getAttribute('zen-workspace-id'));
|
|
|
|
// Create a transparent drag image for Linux
|
|
if (AppConstants.platform === 'linux') {
|
|
const dragImage = document.createElement('canvas');
|
|
dragImage.width = 1;
|
|
dragImage.height = 1;
|
|
event.dataTransfer.setDragImage(dragImage, 0, 0);
|
|
}
|
|
|
|
element.classList.add('dragging');
|
|
} else {
|
|
event.preventDefault();
|
|
}
|
|
}.bind(browser.ZenWorkspaces)
|
|
);
|
|
|
|
element.addEventListener(
|
|
'dragover',
|
|
function (event) {
|
|
if (this.isReorderModeOn(browser) && this.draggedElement) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'move';
|
|
|
|
// Ensure the dragover effect is visible on Linux
|
|
if (AppConstants.platform === 'linux') {
|
|
const targetId = element.getAttribute('zen-workspace-id');
|
|
const draggedId = this.draggedElement.getAttribute('zen-workspace-id');
|
|
if (targetId !== draggedId) {
|
|
element.classList.add('dragover');
|
|
}
|
|
}
|
|
}
|
|
}.bind(browser.ZenWorkspaces)
|
|
);
|
|
|
|
element.addEventListener('dragenter', function (event) {
|
|
if (this.isReorderModeOn(browser) && this.draggedElement) {
|
|
element.classList.add('dragover');
|
|
}
|
|
});
|
|
|
|
element.addEventListener('dragleave', function (event) {
|
|
element.classList.remove('dragover');
|
|
});
|
|
|
|
element.addEventListener(
|
|
'drop',
|
|
async function (event) {
|
|
event.preventDefault();
|
|
element.classList.remove('dragover');
|
|
if (this.isReorderModeOn(browser)) {
|
|
const draggedWorkspaceId = event.dataTransfer.getData('text/plain');
|
|
const targetWorkspaceId = element.getAttribute('zen-workspace-id');
|
|
if (draggedWorkspaceId !== targetWorkspaceId) {
|
|
await this.moveWorkspace(draggedWorkspaceId, targetWorkspaceId);
|
|
}
|
|
if (this.draggedElement) {
|
|
this.draggedElement.classList.remove('dragging');
|
|
this.draggedElement = null;
|
|
}
|
|
}
|
|
}.bind(browser.ZenWorkspaces)
|
|
);
|
|
|
|
element.addEventListener(
|
|
'dragend',
|
|
function (event) {
|
|
if (this.draggedElement) {
|
|
this.draggedElement.classList.remove('dragging');
|
|
this.draggedElement = null;
|
|
}
|
|
const workspaceElements = browser.document.querySelectorAll('.zen-workspace-button');
|
|
for (const elem of workspaceElements) {
|
|
elem.classList.remove('dragover');
|
|
}
|
|
}.bind(browser.ZenWorkspaces)
|
|
);
|
|
|
|
let childs = browser.MozXULElement.parseXULToFragment(`
|
|
<div class="zen-workspace-icon">
|
|
</div>
|
|
<vbox>
|
|
<div class="zen-workspace-name">
|
|
</div>
|
|
<div class="zen-workspace-container" ${containerGroup ? '' : 'hidden="true"'}>
|
|
</div>
|
|
</vbox>
|
|
<image class="toolbarbutton-icon zen-workspace-actions-reorder-icon" ></image>
|
|
<toolbarbutton closemenu="none" class="toolbarbutton-1 zen-workspace-actions">
|
|
<image class="toolbarbutton-icon" id="zen-workspace-actions-menu-icon"></image>
|
|
</toolbarbutton>
|
|
`);
|
|
|
|
// use text content instead of innerHTML to avoid XSS
|
|
childs.querySelector('.zen-workspace-icon').textContent = browser.ZenWorkspaces.getWorkspaceIcon(workspace);
|
|
childs.querySelector('.zen-workspace-name').textContent = workspace.name;
|
|
if (containerGroup) {
|
|
childs.querySelector('.zen-workspace-container').textContent = ContextualIdentityService.getUserContextLabel(
|
|
containerGroup.userContextId
|
|
);
|
|
}
|
|
|
|
childs.querySelector('.zen-workspace-actions').addEventListener(
|
|
'command',
|
|
((event) => {
|
|
let button = event.target;
|
|
this._contextMenuId = button.closest('toolbarbutton[zen-workspace-id]').getAttribute('zen-workspace-id');
|
|
const popup = button.ownerDocument.getElementById('zenWorkspaceActionsMenu');
|
|
popup.openPopup(button, 'after_end');
|
|
}).bind(browser.ZenWorkspaces)
|
|
);
|
|
element.appendChild(childs);
|
|
element.onclick = (async () => {
|
|
if (this.isReorderModeOn(browser)) {
|
|
return; // Return early if reorder mode is on
|
|
}
|
|
if (event.target.closest('.zen-workspace-actions')) {
|
|
return; // Ignore clicks on the actions button
|
|
}
|
|
const workspaceId = element.getAttribute('zen-workspace-id');
|
|
const workspaces = await this._workspaces();
|
|
const workspace = workspaces.workspaces.find((w) => w.uuid === workspaceId);
|
|
await this.changeWorkspace(workspace);
|
|
let panel = this.ownerWindow.document.getElementById('PanelUI-zen-workspaces');
|
|
PanelMultiView.hidePopup(panel);
|
|
this.ownerWindow.document.getElementById('zen-workspaces-button').removeAttribute('open');
|
|
}).bind(browser.ZenWorkspaces);
|
|
return element;
|
|
};
|
|
|
|
const createLastPositionDropTarget = () => {
|
|
const element = browser.document.createXULElement('div');
|
|
element.className = 'zen-workspace-last-place-drop-target';
|
|
|
|
element.addEventListener(
|
|
'dragover',
|
|
function (event) {
|
|
if (this.isReorderModeOn(browser) && this.draggedElement) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'move';
|
|
|
|
// Ensure the dragover effect is visible on Linux
|
|
if (AppConstants.platform === 'linux') {
|
|
element.classList.add('dragover');
|
|
}
|
|
}
|
|
}.bind(browser.ZenWorkspaces)
|
|
);
|
|
|
|
element.addEventListener(
|
|
'dragenter',
|
|
function (event) {
|
|
if (this.isReorderModeOn(browser) && this.draggedElement) {
|
|
element.classList.add('dragover');
|
|
}
|
|
}.bind(browser.ZenWorkspaces)
|
|
);
|
|
|
|
element.addEventListener(
|
|
'dragleave',
|
|
function (event) {
|
|
element.classList.remove('dragover');
|
|
}.bind(browser.ZenWorkspaces)
|
|
);
|
|
|
|
element.addEventListener(
|
|
'drop',
|
|
async function (event) {
|
|
event.preventDefault();
|
|
element.classList.remove('dragover');
|
|
|
|
if (this.isReorderModeOn(browser)) {
|
|
const draggedWorkspaceId = event.dataTransfer.getData('text/plain');
|
|
await this.moveWorkspaceToEnd(draggedWorkspaceId);
|
|
|
|
if (this.draggedElement) {
|
|
this.draggedElement.classList.remove('dragging');
|
|
this.draggedElement = null;
|
|
}
|
|
}
|
|
}.bind(browser.ZenWorkspaces)
|
|
);
|
|
|
|
return element;
|
|
};
|
|
|
|
if (clearCache) {
|
|
browser.ZenWorkspaces._workspaceCache = null;
|
|
browser.ZenWorkspaces._workspaceBookmarksCache = null;
|
|
}
|
|
let workspaces = await browser.ZenWorkspaces._workspaces();
|
|
await browser.ZenWorkspaces.workspaceBookmarks();
|
|
workspaceList.innerHTML = '';
|
|
workspaceList.parentNode.style.display = 'flex';
|
|
if (workspaces.workspaces.length <= 0) {
|
|
workspaceList.innerHTML = 'No workspaces available';
|
|
workspaceList.setAttribute('empty', 'true');
|
|
} else {
|
|
workspaceList.removeAttribute('empty');
|
|
}
|
|
|
|
for (let workspace of workspaces.workspaces) {
|
|
let workspaceElement = createWorkspaceElement(workspace);
|
|
workspaceList.appendChild(workspaceElement);
|
|
}
|
|
|
|
workspaceList.appendChild(createLastPositionDropTarget());
|
|
|
|
if (!ignoreStrip) {
|
|
await browser.ZenWorkspaces._expandWorkspacesStrip(browser);
|
|
browser.ZenWorkspaces._fixIndicatorsNames(workspaces);
|
|
}
|
|
});
|
|
}
|
|
|
|
handlePanelHidden() {
|
|
const workspacesList = document.getElementById('PanelUI-zen-workspaces-list');
|
|
const reorderModeButton = document.getElementById('PanelUI-zen-workspaces-reorder-mode');
|
|
|
|
workspacesList?.removeAttribute('reorder-mode');
|
|
reorderModeButton?.removeAttribute('active');
|
|
this.resetWorkspaceIconSearch();
|
|
this.clearEmojis();
|
|
}
|
|
|
|
async moveWorkspaceToEnd(draggedWorkspaceId) {
|
|
const workspaces = (await this._workspaces()).workspaces;
|
|
const draggedIndex = workspaces.findIndex((w) => w.uuid === draggedWorkspaceId);
|
|
const draggedWorkspace = workspaces.splice(draggedIndex, 1)[0];
|
|
workspaces.push(draggedWorkspace);
|
|
|
|
await ZenWorkspacesStorage.updateWorkspacePositions(workspaces);
|
|
await this._propagateWorkspaceData();
|
|
}
|
|
|
|
isReorderModeOn(browser) {
|
|
return browser.document.getElementById('PanelUI-zen-workspaces-list').getAttribute('reorder-mode') === 'true';
|
|
}
|
|
|
|
toggleReorderMode() {
|
|
const workspacesList = document.getElementById('PanelUI-zen-workspaces-list');
|
|
const reorderModeButton = document.getElementById('PanelUI-zen-workspaces-reorder-mode');
|
|
const isActive = workspacesList.getAttribute('reorder-mode') === 'true';
|
|
if (isActive) {
|
|
workspacesList.removeAttribute('reorder-mode');
|
|
reorderModeButton.removeAttribute('active');
|
|
} else {
|
|
workspacesList.setAttribute('reorder-mode', 'true');
|
|
reorderModeButton.setAttribute('active', 'true');
|
|
}
|
|
|
|
// Update draggable attribute
|
|
const workspaceElements = document.querySelectorAll('.zen-workspace-button');
|
|
workspaceElements.forEach((elem) => {
|
|
// When reorder mode is toggled off, remove draggable attribute
|
|
// When reorder mode is toggled on, set draggable attribute
|
|
if (isActive) {
|
|
elem.removeAttribute('draggable');
|
|
} else {
|
|
elem.setAttribute('draggable', 'true');
|
|
}
|
|
});
|
|
}
|
|
|
|
async moveWorkspace(draggedWorkspaceId, targetWorkspaceId) {
|
|
const workspaces = (await this._workspaces()).workspaces;
|
|
const draggedIndex = workspaces.findIndex((w) => w.uuid === draggedWorkspaceId);
|
|
const draggedWorkspace = workspaces.splice(draggedIndex, 1)[0];
|
|
const targetIndex = workspaces.findIndex((w) => w.uuid === targetWorkspaceId);
|
|
workspaces.splice(targetIndex, 0, draggedWorkspace);
|
|
|
|
await ZenWorkspacesStorage.updateWorkspacePositions(workspaces);
|
|
await this._propagateWorkspaceData();
|
|
}
|
|
|
|
async openWorkspacesDialog(event) {
|
|
if (!this.workspaceEnabled) {
|
|
return;
|
|
}
|
|
let target = event.target.closest('.zen-current-workspace-indicator') || document.getElementById('zen-workspaces-button');
|
|
let panel = document.getElementById('PanelUI-zen-workspaces');
|
|
await this._propagateWorkspaceData({
|
|
ignoreStrip: true,
|
|
clearCache: false,
|
|
});
|
|
PanelMultiView.openPopup(panel, target, {
|
|
position: 'bottomright topright',
|
|
triggerEvent: event,
|
|
}).catch(console.error);
|
|
}
|
|
|
|
async initializeWorkspacesButton() {
|
|
if (!this.workspaceEnabled) {
|
|
return;
|
|
} else if (document.getElementById('zen-workspaces-button')) {
|
|
let button = document.getElementById('zen-workspaces-button');
|
|
button.removeAttribute('hidden');
|
|
return;
|
|
}
|
|
await this._expandWorkspacesStrip();
|
|
}
|
|
|
|
async _expandWorkspacesStrip(browser = window) {
|
|
if (typeof browser.ZenWorkspaces === 'undefined') {
|
|
browser = window;
|
|
}
|
|
let button = browser.document.getElementById('zen-workspaces-button');
|
|
|
|
while (button.firstChild) {
|
|
button.firstChild.remove();
|
|
}
|
|
|
|
if (this._workspacesButtonClickListener) {
|
|
button.removeEventListener('click', this._workspacesButtonClickListener);
|
|
this._workspacesButtonClickListener = null;
|
|
}
|
|
if (this._workspaceButtonContextMenuListener) {
|
|
button.removeEventListener('contextmenu', this._workspaceButtonContextMenuListener);
|
|
this._workspaceButtonContextMenuListener = null;
|
|
}
|
|
|
|
button.setAttribute('showInPrivateBrowsing', 'false');
|
|
button.setAttribute('tooltiptext', 'Workspaces');
|
|
let workspaces = await this._workspaces();
|
|
|
|
for (let workspace of workspaces.workspaces) {
|
|
let workspaceButton = browser.document.createXULElement('toolbarbutton');
|
|
workspaceButton.className = 'subviewbutton';
|
|
workspaceButton.setAttribute('tooltiptext', workspace.name);
|
|
workspaceButton.setAttribute('zen-workspace-id', workspace.uuid);
|
|
|
|
if (this.isWorkspaceActive(workspace)) {
|
|
workspaceButton.setAttribute('active', 'true');
|
|
} else {
|
|
workspaceButton.removeAttribute('active');
|
|
}
|
|
if (workspace.default) {
|
|
workspaceButton.setAttribute('default', 'true');
|
|
} else {
|
|
workspaceButton.removeAttribute('default');
|
|
}
|
|
|
|
workspaceButton.addEventListener('click', async (event) => {
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
await this.changeWorkspace(workspace);
|
|
});
|
|
|
|
let icon = browser.document.createXULElement('div');
|
|
icon.className = 'zen-workspace-icon';
|
|
if (this.workspaceHasIcon(workspace)) {
|
|
icon.textContent = this.getWorkspaceIcon(workspace);
|
|
} else {
|
|
icon.setAttribute('no-icon', 'true');
|
|
}
|
|
workspaceButton.appendChild(icon);
|
|
button.appendChild(workspaceButton);
|
|
}
|
|
|
|
if (workspaces.workspaces.length <= 1) {
|
|
button.setAttribute('dont-show', true);
|
|
} else {
|
|
button.removeAttribute('dont-show');
|
|
}
|
|
|
|
this._workspaceButtonContextMenuListener = (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.openWorkspacesDialog(event);
|
|
};
|
|
button.addEventListener('contextmenu', this._workspaceButtonContextMenuListener.bind(browser.ZenWorkspaces));
|
|
}
|
|
|
|
closeWorkspacesSubView() {
|
|
let parentPanel = document.getElementById('PanelUI-zen-workspaces-multiview');
|
|
parentPanel.goBack(parentPanel);
|
|
}
|
|
|
|
// Workspaces management
|
|
|
|
get _workspaceCreateInput() {
|
|
return document.getElementById('PanelUI-zen-workspaces-create-input');
|
|
}
|
|
|
|
get _workspaceEditDialog() {
|
|
return document.getElementById('PanelUI-zen-workspaces-edit');
|
|
}
|
|
|
|
get _workspaceEditInput() {
|
|
return document.getElementById('PanelUI-zen-workspaces-edit-input');
|
|
}
|
|
|
|
get _workspaceEditIconsContainer() {
|
|
return document.getElementById('PanelUI-zen-workspaces-icon-picker');
|
|
}
|
|
|
|
_deleteAllTabsInWorkspace(workspaceID) {
|
|
gBrowser.removeTabs(
|
|
Array.from(this.allStoredTabs).filter(
|
|
(tab) => tab.getAttribute('zen-workspace-id') === workspaceID && !tab.hasAttribute('zen-empty-tab')
|
|
),
|
|
{
|
|
animate: false,
|
|
skipSessionStore: true,
|
|
closeWindowWithLastTab: false,
|
|
}
|
|
);
|
|
}
|
|
|
|
moveTabToWorkspace(tab, workspaceID) {
|
|
const parent = tab.pinned ? '#vertical-pinned-tabs-container ' : '#tabbrowser-arrowscrollbox ';
|
|
const container = document.querySelector(parent + `.zen-workspace-tabs-section[zen-workspace-id="${workspaceID}"]`);
|
|
|
|
if (container?.contains(tab)) {
|
|
return false;
|
|
}
|
|
|
|
tab.setAttribute('zen-workspace-id', workspaceID);
|
|
if (tab.hasAttribute('zen-essential')) {
|
|
return false;
|
|
}
|
|
|
|
if (container) {
|
|
container.insertBefore(tab, container.lastChild);
|
|
}
|
|
// also change glance tab if it's the same tab
|
|
const glanceTab = tab.querySelector('.tabbrowser-tab[zen-glance-tab]');
|
|
if (glanceTab) {
|
|
glanceTab.setAttribute('zen-workspace-id', workspaceID);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
_prepareNewWorkspace(window) {
|
|
document.documentElement.setAttribute('zen-workspace-id', window.uuid);
|
|
let tabCount = 0;
|
|
for (let tab of gBrowser.tabs) {
|
|
const isEssential = tab.getAttribute('zen-essential') === 'true';
|
|
if (!tab.hasAttribute('zen-workspace-id') && !tab.pinned && !isEssential) {
|
|
this.moveTabToWorkspace(tab, window.uuid);
|
|
tabCount++;
|
|
}
|
|
}
|
|
if (tabCount === 0) {
|
|
this.selectEmptyTab();
|
|
}
|
|
}
|
|
|
|
async saveWorkspaceFromCreate() {
|
|
let workspaceName = this._workspaceCreateInput.value;
|
|
if (!workspaceName) {
|
|
return;
|
|
}
|
|
this._workspaceCreateInput.value = '';
|
|
let icon = document.querySelector('#PanelUI-zen-workspaces-icon-picker-wrapper [selected]');
|
|
icon?.removeAttribute('selected');
|
|
await this.createAndSaveWorkspace(workspaceName, false, icon?.label);
|
|
this.goToPreviousSubView();
|
|
}
|
|
|
|
async saveWorkspaceFromEdit() {
|
|
let workspaceUuid = this._workspaceEditDialog.getAttribute('data-workspace-uuid');
|
|
let workspaceName = this._workspaceEditInput.value;
|
|
if (!workspaceName) {
|
|
return;
|
|
}
|
|
this._workspaceEditInput.value = '';
|
|
let icon = document.querySelector('#PanelUI-zen-workspaces-icon-picker-wrapper [selected]');
|
|
icon?.removeAttribute('selected');
|
|
let workspaces = (await this._workspaces()).workspaces;
|
|
let workspaceData = workspaces.find((workspace) => workspace.uuid === workspaceUuid);
|
|
workspaceData.name = workspaceName;
|
|
workspaceData.icon = icon?.label;
|
|
await this.saveWorkspace(workspaceData);
|
|
this.goToPreviousSubView();
|
|
}
|
|
|
|
onWorkspaceCreationNameChange(event) {
|
|
let button = document.getElementById('PanelUI-zen-workspaces-create-save');
|
|
if (this._workspaceCreateInput.value === '') {
|
|
button.setAttribute('disabled', 'true');
|
|
return;
|
|
}
|
|
button.removeAttribute('disabled');
|
|
}
|
|
|
|
onWorkspaceEditChange(icon) {
|
|
let button = document.getElementById('PanelUI-zen-workspaces-edit-save');
|
|
let name = this._workspaceEditInput.value;
|
|
if (
|
|
name === this._workspaceEditInput.getAttribute('data-initial-value') &&
|
|
icon === this._workspaceEditIconsContainer.getAttribute('data-initial-value')
|
|
) {
|
|
button.setAttribute('disabled', 'true');
|
|
return;
|
|
}
|
|
button.removeAttribute('disabled');
|
|
}
|
|
|
|
addChangeListeners(func) {
|
|
if (!this._changeListeners) {
|
|
this._changeListeners = [];
|
|
}
|
|
this._changeListeners.push(func);
|
|
}
|
|
|
|
async changeWorkspace(window, ...args) {
|
|
if (!this.workspaceEnabled || this._inChangingWorkspace) {
|
|
return;
|
|
}
|
|
this._inChangingWorkspace = true;
|
|
try {
|
|
await this._performWorkspaceChange(window, ...args);
|
|
} catch (e) {
|
|
console.error('ZenWorkspaces: Error changing workspace', e);
|
|
}
|
|
this._inChangingWorkspace = false;
|
|
}
|
|
|
|
_cancelSwipeAnimation() {
|
|
const currentWorkspace = this.activeWorkspace;
|
|
this._animateTabs({ uuid: currentWorkspace }, true);
|
|
}
|
|
|
|
async _performWorkspaceChange(window, { onInit = false, alwaysChange = false, whileScrolling = false } = {}) {
|
|
const previousWorkspace = await this.getActiveWorkspace();
|
|
alwaysChange = alwaysChange || onInit;
|
|
|
|
this.activeWorkspace = window.uuid;
|
|
if (previousWorkspace && previousWorkspace.uuid === window.uuid && !alwaysChange) {
|
|
this._cancelSwipeAnimation();
|
|
return;
|
|
}
|
|
|
|
const containerId = window.containerTabId?.toString();
|
|
const workspaces = await this._workspaces();
|
|
|
|
// Refresh tab cache
|
|
gBrowser.verticalPinnedTabsContainer = this.pinnedTabsContainer || gBrowser.verticalPinnedTabsContainer;
|
|
gBrowser.tabContainer.verticalPinnedTabsContainer =
|
|
this.pinnedTabsContainer || gBrowser.tabContainer.verticalPinnedTabsContainer;
|
|
// Move empty tab to the new workspace
|
|
this._moveEmptyTabToWorkspace(window.uuid);
|
|
|
|
this.tabContainer._invalidateCachedTabs();
|
|
if (!whileScrolling) {
|
|
await this._organizeWorkspaceStripLocations(previousWorkspace);
|
|
}
|
|
|
|
// First pass: Handle tab visibility and workspace ID assignment
|
|
const prevTabUsed = this._processTabVisibility(window.uuid, containerId, workspaces, onInit);
|
|
|
|
// Second pass: Handle tab selection
|
|
this.tabContainer._invalidateCachedTabs();
|
|
const tabToSelect = await this._handleTabSelection(
|
|
window,
|
|
onInit,
|
|
containerId,
|
|
workspaces,
|
|
previousWorkspace.uuid,
|
|
prevTabUsed
|
|
);
|
|
|
|
// Update UI and state
|
|
await this._updateWorkspaceState(window, onInit, tabToSelect);
|
|
}
|
|
|
|
_moveEmptyTabToWorkspace(workspaceUuid) {
|
|
this._makeSureEmptyTabIsLast();
|
|
}
|
|
|
|
_makeSureEmptyTabIsLast() {
|
|
const emptyTab = this._emptyTab;
|
|
if (emptyTab) {
|
|
const container = this.activeWorkspaceStrip;
|
|
if (container) {
|
|
container.insertBefore(emptyTab, container.lastChild);
|
|
}
|
|
}
|
|
this._fixTabPositions();
|
|
}
|
|
|
|
_fixTabPositions() {
|
|
// Fix tabs _tPos values relative to the actual order
|
|
const tabs = gBrowser.tabs;
|
|
for (let i = 0; i < tabs.length; i++) {
|
|
tabs[i]._tPos = i;
|
|
}
|
|
}
|
|
|
|
_updateMarginTopPinnedTabs(arrowscrollbox, pinnedContainer) {
|
|
if (arrowscrollbox) {
|
|
arrowscrollbox.style.marginTop = pinnedContainer.getBoundingClientRect().height + 'px';
|
|
}
|
|
}
|
|
|
|
async _organizeWorkspaceStripLocations(workspace, justMove = false, offsetPixels = 0) {
|
|
const workspaces = await this._workspaces();
|
|
let workspaceIndex = workspaces.workspaces.findIndex((w) => w.uuid === workspace.uuid);
|
|
if (!justMove) {
|
|
this._fixIndicatorsNames(workspaces);
|
|
}
|
|
for (const otherWorkspace of workspaces.workspaces) {
|
|
const selector = `.zen-workspace-tabs-section[zen-workspace-id="${otherWorkspace.uuid}"]`;
|
|
const newTransform = -(workspaceIndex - workspaces.workspaces.indexOf(otherWorkspace)) * 100;
|
|
for (const container of document.querySelectorAll(selector)) {
|
|
container.style.transform = `translateX(${newTransform + offsetPixels / 2}%)`;
|
|
if (!offsetPixels && !container.hasAttribute('active')) {
|
|
container.setAttribute('hidden', 'true');
|
|
} else {
|
|
container.removeAttribute('hidden');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
updateWorkspaceIndicator(currentWorkspace, workspaceIndicator) {
|
|
if (!workspaceIndicator) {
|
|
return;
|
|
}
|
|
const indicatorName = workspaceIndicator.querySelector('.zen-current-workspace-indicator-name');
|
|
const indicatorIcon = workspaceIndicator.querySelector('.zen-current-workspace-indicator-icon');
|
|
|
|
if (this.workspaceHasIcon(currentWorkspace)) {
|
|
indicatorIcon.removeAttribute('no-icon');
|
|
} else {
|
|
indicatorIcon.setAttribute('no-icon', 'true');
|
|
}
|
|
indicatorIcon.textContent = this.getWorkspaceIcon(currentWorkspace);
|
|
indicatorName.textContent = currentWorkspace.name;
|
|
}
|
|
|
|
_fixIndicatorsNames(workspaces) {
|
|
for (const workspace of workspaces.workspaces) {
|
|
const workspaceIndicator = document.querySelector(
|
|
`#zen-current-workspace-indicator-container .zen-workspace-tabs-section[zen-workspace-id="${workspace.uuid}"]`
|
|
);
|
|
this.updateWorkspaceIndicator(workspace, workspaceIndicator);
|
|
}
|
|
}
|
|
|
|
async _animateTabs(newWorkspace, shouldAnimate, tabToSelect = null) {
|
|
this._animatingChange = true;
|
|
const animations = [];
|
|
const workspaces = await this._workspaces();
|
|
const newWorkspaceIndex = workspaces.workspaces.findIndex((w) => w.uuid === newWorkspace.uuid);
|
|
for (const element of document.querySelectorAll('.zen-workspace-tabs-section')) {
|
|
const existingTransform = element.style.transform;
|
|
const elementWorkspaceId = element.getAttribute('zen-workspace-id');
|
|
const elementWorkspaceIndex = workspaces.workspaces.findIndex((w) => w.uuid === elementWorkspaceId);
|
|
const offset = -(newWorkspaceIndex - elementWorkspaceIndex) * 100;
|
|
const newTransform = `translateX(${offset}%)`;
|
|
if (shouldAnimate) {
|
|
element.removeAttribute('hidden');
|
|
animations.push(
|
|
gZenUIManager.motion.animate(
|
|
element,
|
|
{
|
|
transform: existingTransform ? [existingTransform, newTransform] : newTransform,
|
|
},
|
|
{
|
|
type: 'spring',
|
|
bounce: 0,
|
|
duration: 0.3,
|
|
}
|
|
)
|
|
);
|
|
}
|
|
if (offset === 0) {
|
|
element.setAttribute('active', 'true');
|
|
if (tabToSelect != gBrowser.selectedTab) {
|
|
gBrowser.selectedTab = tabToSelect;
|
|
}
|
|
} else {
|
|
element.removeAttribute('active');
|
|
}
|
|
}
|
|
await Promise.all(animations);
|
|
this._animatingChange = false;
|
|
}
|
|
|
|
_processTabVisibility(workspaceUuid, containerId, workspaces, onInit) {
|
|
const hiddenTabs = [];
|
|
const visibleTabs = gBrowser.tabContainer.visibleTabs;
|
|
for (const tab of gBrowser.tabs) {
|
|
if (!this._shouldShowTab(tab, workspaceUuid, containerId, workspaces)) {
|
|
hiddenTabs.push(tab);
|
|
} else if (tab.hasAttribute('zen-essential')) {
|
|
gBrowser.showTab(tab, undefined, true);
|
|
}
|
|
}
|
|
// If there's no more visible tabs, make a new tab visible
|
|
// or if ALL the visible tabs are essentials or we have our selected
|
|
// tab hidden, select a new tab
|
|
let prevTabUsed = null;
|
|
if (
|
|
(hiddenTabs.length === visibleTabs.length ||
|
|
visibleTabs.every((tab) => tab.getAttribute('zen-essential') === 'true') ||
|
|
hiddenTabs.includes(gBrowser.selectedTab)) &&
|
|
gZenVerticalTabsManager._canReplaceNewTab &&
|
|
!onInit
|
|
) {
|
|
prevTabUsed = gBrowser.selectedTab;
|
|
this.selectEmptyTab(null, false);
|
|
}
|
|
for (const tab of hiddenTabs) {
|
|
gBrowser.hideTab(tab, undefined, true);
|
|
}
|
|
return prevTabUsed;
|
|
}
|
|
|
|
// Only use it in gZenPinnedTabsManager, when initializing essential tabs
|
|
essentialShouldShowTab(tab) {
|
|
if (tab.getAttribute('zen-essential') !== 'true') {
|
|
return true;
|
|
}
|
|
const workspaces = this._workspaceCache;
|
|
if (!workspaces) {
|
|
return true;
|
|
}
|
|
const containerId = (
|
|
workspaces.workspaces.find((workspace) => workspace.uuid === this.activeWorkspace) || {}
|
|
)?.containerTabId?.toString();
|
|
return this._shouldShowTab(tab, this.activeWorkspace, containerId, workspaces);
|
|
}
|
|
|
|
_shouldShowTab(tab, workspaceUuid, containerId, workspaces) {
|
|
const isEssential = tab.getAttribute('zen-essential') === 'true';
|
|
const tabWorkspaceId = tab.getAttribute('zen-workspace-id');
|
|
const tabContextId = tab.getAttribute('usercontextid');
|
|
|
|
if (tab.hasAttribute('zen-glance-tab')) {
|
|
return true; // Always show glance tabs
|
|
}
|
|
|
|
// Handle essential tabs
|
|
if (isEssential) {
|
|
if (!this.containerSpecificEssentials) {
|
|
return true; // Show all essential tabs when containerSpecificEssentials is false
|
|
}
|
|
|
|
if (containerId) {
|
|
// In workspaces with default container: Show essentials that match the container
|
|
return tabContextId === containerId;
|
|
} else {
|
|
// In workspaces without a default container: Show essentials that aren't in container-specific workspaces
|
|
// or have usercontextid="0" or no usercontextid
|
|
return (
|
|
!tabContextId ||
|
|
tabContextId === '0' ||
|
|
!workspaces.workspaces.some((workspace) => workspace.containerTabId === parseInt(tabContextId, 10))
|
|
);
|
|
}
|
|
}
|
|
|
|
// For non-essential tabs (both normal and pinned)
|
|
if (!tabWorkspaceId) {
|
|
// Assign workspace ID to tabs without one
|
|
this.moveTabToWorkspace(tab, workspaceUuid);
|
|
return true;
|
|
}
|
|
|
|
// Show if tab belongs to current workspace
|
|
return tabWorkspaceId === workspaceUuid;
|
|
}
|
|
|
|
_shouldChangeToTab(aTab) {
|
|
return !(aTab?.hasAttribute('zen-essential') || (aTab?.pinned && aTab?.hasAttribute('pending')));
|
|
}
|
|
|
|
async _handleTabSelection(window, onInit, containerId, workspaces, previousWorkspaceId, prevTabUsed) {
|
|
const currentSelectedTab = prevTabUsed || gBrowser.selectedTab;
|
|
const oldWorkspaceId = previousWorkspaceId;
|
|
const lastSelectedTab = this._lastSelectedWorkspaceTabs[window.uuid];
|
|
|
|
// Save current tab as last selected for old workspace if it shouldn't be visible in new workspace
|
|
if (oldWorkspaceId && oldWorkspaceId !== window.uuid) {
|
|
this._lastSelectedWorkspaceTabs[oldWorkspaceId] = currentSelectedTab;
|
|
}
|
|
|
|
let tabToSelect = null;
|
|
// Try last selected tab if it is visible
|
|
if (lastSelectedTab && this._shouldShowTab(lastSelectedTab, window.uuid, containerId, workspaces)) {
|
|
tabToSelect = lastSelectedTab;
|
|
}
|
|
// Find first suitable tab
|
|
else {
|
|
tabToSelect = gBrowser.visibleTabs.find((tab) => !tab.pinned);
|
|
if (!tabToSelect && gBrowser.visibleTabs.length) {
|
|
tabToSelect = gBrowser.visibleTabs[gBrowser.visibleTabs.length - 1];
|
|
}
|
|
if (!tabToSelect || !this._shouldChangeToTab(tabToSelect)) {
|
|
// Never select an essential tab
|
|
tabToSelect = null;
|
|
}
|
|
}
|
|
|
|
// If we found a tab to select, select it
|
|
if (!onInit && !tabToSelect) {
|
|
// Create new tab if needed and no suitable tab was found
|
|
const newTab = this.selectEmptyTab();
|
|
tabToSelect = newTab;
|
|
}
|
|
if (tabToSelect) {
|
|
tabToSelect._visuallySelected = true;
|
|
}
|
|
|
|
// Always make sure we always unselect the tab from the old workspace
|
|
if (currentSelectedTab && currentSelectedTab !== tabToSelect) {
|
|
currentSelectedTab._selected = false;
|
|
if (
|
|
!this._shouldShowTab(currentSelectedTab, window.uuid, containerId, workspaces) &&
|
|
currentSelectedTab.hasAttribute('zen-essential')
|
|
) {
|
|
gBrowser.hideTab(currentSelectedTab, undefined, true);
|
|
}
|
|
}
|
|
return tabToSelect;
|
|
}
|
|
|
|
async _updateWorkspaceState(workspace, onInit, tabToSelect) {
|
|
// Update document state
|
|
document.documentElement.setAttribute('zen-workspace-id', workspace.uuid);
|
|
|
|
// Recalculate new tab observers
|
|
gBrowser.tabContainer.observe(null, 'nsPref:changed', 'privacy.userContext.enabled');
|
|
|
|
// Update workspace UI
|
|
await this._updateWorkspacesChangeContextMenu();
|
|
gZenUIManager.updateTabsToolbar();
|
|
await this._propagateWorkspaceData({ clearCache: false });
|
|
|
|
gZenThemePicker.onWorkspaceChange(workspace);
|
|
|
|
document.getElementById('zen-tabs-wrapper').style.scrollbarWidth = 'none';
|
|
await this._animateTabs(workspace, !onInit && !this._animatingChange, tabToSelect);
|
|
await this._organizeWorkspaceStripLocations(workspace, true);
|
|
document.getElementById('zen-tabs-wrapper').style.scrollbarWidth = '';
|
|
|
|
// Notify listeners
|
|
if (this._changeListeners?.length) {
|
|
for (const listener of this._changeListeners) {
|
|
await listener(workspace, onInit);
|
|
}
|
|
}
|
|
|
|
// Reset bookmarks
|
|
this._invalidateBookmarkContainers();
|
|
|
|
// Update workspace indicator
|
|
await this.updateWorkspaceIndicator(workspace, this.workspaceIndicator);
|
|
|
|
// Fix ctrl+tab behavior. Note, we dont call it with "await" because we dont want to wait for it
|
|
this._fixCtrlTabBehavior();
|
|
|
|
// Bug: When updating from previous versions, we used to hide the tabs not used in the new workspace
|
|
// we now need to show them again
|
|
if (onInit) {
|
|
for (const tab of this.allStoredTabs) {
|
|
if (!tab.hasAttribute('zen-essential')) {
|
|
gBrowser.showTab(tab);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async _fixCtrlTabBehavior() {
|
|
ctrlTab.uninit();
|
|
ctrlTab.readPref();
|
|
}
|
|
|
|
_invalidateBookmarkContainers() {
|
|
for (let i = 0, len = this.bookmarkMenus.length; i < len; i++) {
|
|
const element = document.getElementById(this.bookmarkMenus[i]);
|
|
if (element && element._placesView) {
|
|
const placesView = element._placesView;
|
|
placesView.invalidateContainer(placesView._resultNode);
|
|
}
|
|
}
|
|
}
|
|
|
|
async _updateWorkspacesChangeContextMenu() {
|
|
const workspaces = await this._workspaces();
|
|
|
|
const menuPopup = document.getElementById('context-zen-change-workspace-tab-menu-popup');
|
|
if (!menuPopup) {
|
|
return;
|
|
}
|
|
menuPopup.innerHTML = '';
|
|
|
|
const activeWorkspace = await this.getActiveWorkspace();
|
|
|
|
for (let workspace of workspaces.workspaces) {
|
|
const menuItem = document.createXULElement('menuitem');
|
|
menuItem.setAttribute('label', workspace.name);
|
|
menuItem.setAttribute('zen-workspace-id', workspace.uuid);
|
|
menuItem.setAttribute('command', 'cmd_zenChangeWorkspaceTab');
|
|
|
|
if (workspace.uuid === activeWorkspace.uuid) {
|
|
menuItem.setAttribute('disabled', 'true');
|
|
}
|
|
|
|
menuPopup.appendChild(menuItem);
|
|
}
|
|
}
|
|
|
|
_createWorkspaceData(name, isDefault, icon, tabs, moveTabs = true) {
|
|
let window = {
|
|
uuid: gZenUIManager.generateUuidv4(),
|
|
default: isDefault,
|
|
icon: icon,
|
|
name: name,
|
|
theme: ZenThemePicker.getTheme([]),
|
|
};
|
|
if (moveTabs) {
|
|
this._prepareNewWorkspace(window);
|
|
const perifery = document.querySelector('#tabbrowser-arrowscrollbox-periphery[hidden]');
|
|
perifery?.removeAttribute('hidden');
|
|
this._createWorkspaceTabsSection(window, tabs, perifery);
|
|
perifery.setAttribute('hidden', 'true');
|
|
}
|
|
return window;
|
|
}
|
|
|
|
async createAndSaveWorkspace(name = 'New Workspace', isDefault = false, icon = undefined, dontChange = false) {
|
|
if (!this.workspaceEnabled) {
|
|
return;
|
|
}
|
|
// get extra tabs remaning (e.g. on new profiles) and just move them to the new workspace
|
|
const extraTabs = Array.from(gBrowser.tabContainer.arrowScrollbox.children).filter(
|
|
(child) => child.tagName === 'tab' && !child.hasAttribute('zen-workspace-id')
|
|
);
|
|
let workspaceData = this._createWorkspaceData(name, isDefault, icon, extraTabs, !dontChange);
|
|
await this.saveWorkspace(workspaceData, dontChange);
|
|
if (!dontChange) {
|
|
this.registerPinnedResizeObserver();
|
|
let changed = extraTabs.length > 0;
|
|
if (changed) {
|
|
gBrowser.tabContainer._invalidateCachedTabs();
|
|
gBrowser.selectedTab = extraTabs[0];
|
|
}
|
|
await this.changeWorkspace(workspaceData);
|
|
}
|
|
this.onWindowResize();
|
|
return workspaceData;
|
|
}
|
|
|
|
updateTabsContainers() {
|
|
this.onPinnedTabsResize([{ target: this.pinnedTabsContainer }]);
|
|
}
|
|
|
|
updateShouldHideSeparator(arrowScrollbox, pinnedContainer) {
|
|
// <= 2 because we have the empty tab and the new tab button
|
|
const shouldHideSeparator =
|
|
pinnedContainer.children.length === 1 ||
|
|
Array.from(arrowScrollbox.children).filter(
|
|
(child) => !child.hasAttribute('hidden') && !child.hasAttribute('zen-empty-tab')
|
|
).length <= 1;
|
|
if (shouldHideSeparator) {
|
|
pinnedContainer.setAttribute('hide-separator', 'true');
|
|
} else {
|
|
pinnedContainer.removeAttribute('hide-separator');
|
|
}
|
|
}
|
|
|
|
onPinnedTabsResize(entries) {
|
|
if (!this._hasInitializedTabsStrip) {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const workspaceId = entry.target.getAttribute('zen-workspace-id');
|
|
const arrowScrollbox = document.querySelector(
|
|
`#tabbrowser-arrowscrollbox .zen-workspace-tabs-section[zen-workspace-id="${workspaceId}"]`
|
|
);
|
|
this._updateMarginTopPinnedTabs(arrowScrollbox, entry.target);
|
|
this.updateShouldHideSeparator(arrowScrollbox, entry.target);
|
|
}
|
|
}
|
|
|
|
async onTabBrowserInserted(event) {
|
|
let tab = event.originalTarget;
|
|
const isEssential = tab.getAttribute('zen-essential') === 'true';
|
|
const workspaceID = tab.getAttribute('zen-workspace-id');
|
|
|
|
if (!this.workspaceEnabled || isEssential) {
|
|
return;
|
|
}
|
|
|
|
if (workspaceID) {
|
|
if (tab.hasAttribute('change-workspace') && this.moveTabToWorkspace(tab, workspaceID)) {
|
|
this._lastSelectedWorkspaceTabs[workspaceID] = tab;
|
|
tab.removeAttribute('change-workspace');
|
|
await this.changeWorkspace({ uuid: workspaceID, containerTabId: tab.getAttribute('usercontextid') }, { onInit: true });
|
|
}
|
|
return;
|
|
}
|
|
|
|
let activeWorkspace = await this.getActiveWorkspace();
|
|
if (!activeWorkspace) {
|
|
return;
|
|
}
|
|
tab.setAttribute('zen-workspace-id', activeWorkspace.uuid);
|
|
}
|
|
|
|
async onLocationChange(browser) {
|
|
gZenCompactModeManager.sidebar.toggleAttribute('zen-has-empty-tab', gBrowser.selectedTab.hasAttribute('zen-empty-tab'));
|
|
if (!this.workspaceEnabled || this._inChangingWorkspace || this._isClosingWindow) {
|
|
return;
|
|
}
|
|
|
|
let tab = gBrowser.getTabForBrowser(browser);
|
|
if (tab.hasAttribute('zen-glance-tab')) {
|
|
// Extract from parent node so we are not selecting the wrong (current) tab
|
|
tab = tab.parentNode.closest('.tabbrowser-tab');
|
|
console.assert(tab, 'Tab not found for zen-glance-tab');
|
|
}
|
|
const workspaceID = tab.getAttribute('zen-workspace-id');
|
|
const isEssential = tab.getAttribute('zen-essential') === 'true';
|
|
|
|
if (tab.hasAttribute('zen-empty-tab')) {
|
|
return;
|
|
}
|
|
|
|
if (!isEssential) {
|
|
const activeWorkspace = await this.getActiveWorkspace();
|
|
if (!activeWorkspace) {
|
|
return;
|
|
}
|
|
|
|
// Only update last selected tab for non-essential tabs in their workspace
|
|
if (workspaceID === activeWorkspace.uuid) {
|
|
this._lastSelectedWorkspaceTabs[workspaceID] = tab;
|
|
}
|
|
|
|
// Switch workspace if needed
|
|
if (workspaceID && workspaceID !== activeWorkspace.uuid && this._hasInitializedTabsStrip) {
|
|
await this.changeWorkspace({ uuid: workspaceID });
|
|
}
|
|
}
|
|
}
|
|
|
|
makeSurePinTabIsInCorrectPosition() {
|
|
if (!this.pinnedTabsContainer) {
|
|
return 0; // until we initialize the pinned tabs container
|
|
}
|
|
const tabsInsidePinTab = Array.from(this.pinnedTabsContainer.parentElement.children).filter(
|
|
(child) => child.tagName === 'tab'
|
|
);
|
|
let changed = false;
|
|
for (const tab of tabsInsidePinTab) {
|
|
if (tab.getAttribute('zen-glance-tab') === 'true') {
|
|
continue;
|
|
}
|
|
if (tab.getAttribute('zen-essential') === 'true') {
|
|
const container = document.getElementById('zen-essentials-container');
|
|
container.appendChild(tab);
|
|
changed = true;
|
|
continue;
|
|
}
|
|
const workspaceId = tab.getAttribute('zen-workspace-id');
|
|
if (!workspaceId) {
|
|
continue;
|
|
}
|
|
const contaienr = document.querySelector(
|
|
`#vertical-pinned-tabs-container .zen-workspace-tabs-section[zen-workspace-id="${workspaceId}"]`
|
|
);
|
|
contaienr.insertBefore(tab, contaienr.lastChild);
|
|
changed = true;
|
|
}
|
|
if (changed) {
|
|
gBrowser.tabContainer._invalidateCachedTabs();
|
|
}
|
|
// Return the number of essentials INSIDE the pinned tabs container so we can correctly change their parent
|
|
return Array.from(this.pinnedTabsContainer.children).filter((child) => child.getAttribute('zen-essential') === 'true')
|
|
.length;
|
|
}
|
|
|
|
// Context menu management
|
|
|
|
_contextMenuId = null;
|
|
async updateContextMenu(_) {
|
|
console.assert(this._contextMenuId, 'No context menu ID set');
|
|
document
|
|
.querySelector(`#PanelUI-zen-workspaces [zen-workspace-id="${this._contextMenuId}"] .zen-workspace-actions`)
|
|
.setAttribute('active', 'true');
|
|
const workspaces = await this._workspaces();
|
|
let deleteMenuItem = document.getElementById('context_zenDeleteWorkspace');
|
|
if (
|
|
workspaces.workspaces.length <= 1 ||
|
|
workspaces.workspaces.find((workspace) => workspace.uuid === this._contextMenuId).default
|
|
) {
|
|
deleteMenuItem.setAttribute('disabled', 'true');
|
|
} else {
|
|
deleteMenuItem.removeAttribute('disabled');
|
|
}
|
|
let defaultMenuItem = document.getElementById('context_zenSetAsDefaultWorkspace');
|
|
if (workspaces.workspaces.find((workspace) => workspace.uuid === this._contextMenuId).default) {
|
|
defaultMenuItem.setAttribute('disabled', 'true');
|
|
} else {
|
|
defaultMenuItem.removeAttribute('disabled');
|
|
}
|
|
let openMenuItem = document.getElementById('context_zenOpenWorkspace');
|
|
if (
|
|
workspaces.workspaces.find((workspace) => workspace.uuid === this._contextMenuId && this.isWorkspaceActive(workspace))
|
|
) {
|
|
openMenuItem.setAttribute('disabled', 'true');
|
|
} else {
|
|
openMenuItem.removeAttribute('disabled');
|
|
}
|
|
const openInContainerMenuItem = document.getElementById('context_zenWorkspacesOpenInContainerTab');
|
|
if (this.shouldShowContainers) {
|
|
openInContainerMenuItem.removeAttribute('hidden');
|
|
} else {
|
|
openInContainerMenuItem.setAttribute('hidden', 'true');
|
|
}
|
|
}
|
|
|
|
async contextChangeContainerTab(event) {
|
|
let workspaces = await this._workspaces();
|
|
let workspace = workspaces.workspaces.find((workspace) => workspace.uuid === this._contextMenuId);
|
|
let userContextId = parseInt(event.target.getAttribute('data-usercontextid'));
|
|
workspace.containerTabId = userContextId;
|
|
await this.saveWorkspace(workspace);
|
|
}
|
|
|
|
onContextMenuClose() {
|
|
let target = document.querySelector(
|
|
`#PanelUI-zen-workspaces [zen-workspace-id="${this._contextMenuId}"] .zen-workspace-actions`
|
|
);
|
|
if (target) {
|
|
target.removeAttribute('active');
|
|
}
|
|
this._contextMenuId = null;
|
|
}
|
|
|
|
findTabToBlur(tab) {
|
|
if ((!this._shouldChangeToTab(tab) || !tab) && this._emptyTab) {
|
|
return this._emptyTab;
|
|
}
|
|
return tab;
|
|
}
|
|
|
|
async setDefaultWorkspace() {
|
|
await ZenWorkspacesStorage.setDefaultWorkspace(this._contextMenuId);
|
|
await this._propagateWorkspaceData();
|
|
}
|
|
|
|
async openWorkspace() {
|
|
let workspaces = await this._workspaces();
|
|
let workspace = workspaces.workspaces.find((workspace) => workspace.uuid === this._contextMenuId);
|
|
await this.changeWorkspace(workspace);
|
|
}
|
|
|
|
async contextDelete(event) {
|
|
this.__contextIsDelete = true;
|
|
event.stopPropagation();
|
|
await this.removeWorkspace(this._contextMenuId);
|
|
this.__contextIsDelete = false;
|
|
}
|
|
|
|
async contextEdit(event) {
|
|
event.stopPropagation();
|
|
await this.openEditDialog(this._contextMenuId);
|
|
}
|
|
|
|
get emojis() {
|
|
if (this._emojis) {
|
|
return this._emojis;
|
|
}
|
|
const lazy = {};
|
|
Services.scriptloader.loadSubScript('chrome://browser/content/zen-components/ZenEmojies.mjs', lazy);
|
|
this._emojis = lazy.zenGlobalEmojis();
|
|
return this._emojis;
|
|
}
|
|
|
|
clearEmojis() {
|
|
// Unload from memory
|
|
this._emojis = null;
|
|
}
|
|
|
|
async changeWorkspaceShortcut(offset = 1, whileScrolling = false) {
|
|
// Cycle through workspaces
|
|
let workspaces = await this._workspaces();
|
|
let activeWorkspace = await this.getActiveWorkspace();
|
|
let workspaceIndex = workspaces.workspaces.indexOf(activeWorkspace);
|
|
|
|
// note: offset can be negative
|
|
let targetIndex = workspaceIndex + offset;
|
|
if (this.shouldWrapAroundNavigation) {
|
|
// Add length to handle negative indices and loop
|
|
targetIndex = (targetIndex + workspaces.workspaces.length) % workspaces.workspaces.length;
|
|
} else {
|
|
// Clamp within bounds to disable looping
|
|
targetIndex = Math.max(0, Math.min(workspaces.workspaces.length - 1, targetIndex));
|
|
}
|
|
|
|
let nextWorkspace = workspaces.workspaces[targetIndex];
|
|
await this.changeWorkspace(nextWorkspace, { whileScrolling });
|
|
}
|
|
|
|
_initializeWorkspaceTabContextMenus() {
|
|
const menu = document.createXULElement('menu');
|
|
menu.setAttribute('id', 'context-zen-change-workspace-tab');
|
|
menu.setAttribute('data-l10n-id', 'context-zen-change-workspace-tab');
|
|
|
|
const menuPopup = document.createXULElement('menupopup');
|
|
menuPopup.setAttribute('id', 'context-zen-change-workspace-tab-menu-popup');
|
|
|
|
menu.appendChild(menuPopup);
|
|
|
|
document.getElementById('context_closeDuplicateTabs').after(menu);
|
|
}
|
|
|
|
async changeTabWorkspace(workspaceID) {
|
|
const tabs = TabContextMenu.contextTab.multiselected ? gBrowser.selectedTabs : [TabContextMenu.contextTab];
|
|
document.getElementById('tabContextMenu').hidePopup();
|
|
const previousWorkspaceID = document.documentElement.getAttribute('zen-workspace-id');
|
|
for (let tab of tabs) {
|
|
this.moveTabToWorkspace(tab, workspaceID);
|
|
if (this._lastSelectedWorkspaceTabs[previousWorkspaceID] === tab) {
|
|
// This tab is no longer the last selected tab in the previous workspace because it's being moved to
|
|
// the current workspace
|
|
delete this._lastSelectedWorkspaceTabs[previousWorkspaceID];
|
|
}
|
|
}
|
|
// Make sure we select the last tab in the new workspace
|
|
this._lastSelectedWorkspaceTabs[workspaceID] = tabs[tabs.length - 1];
|
|
const workspaces = await this._workspaces();
|
|
await this.changeWorkspace(workspaces.workspaces.find((workspace) => workspace.uuid === workspaceID));
|
|
}
|
|
|
|
// Tab browser utilities
|
|
createContainerTabMenu(event) {
|
|
let window = event.target.ownerGlobal;
|
|
const workspace = this._workspaceCache.workspaces.find((workspace) => this._contextMenuId === workspace.uuid);
|
|
let containerTabId = workspace.containerTabId;
|
|
return window.createUserContextMenu(event, {
|
|
isContextMenu: true,
|
|
excludeUserContextId: containerTabId,
|
|
showDefaultTab: true,
|
|
});
|
|
}
|
|
|
|
getContextIdIfNeeded(userContextId, fromExternal, allowInheritPrincipal) {
|
|
if (!this.workspaceEnabled) {
|
|
return [userContextId, false, undefined];
|
|
}
|
|
|
|
if (this.shouldForceContainerTabsToWorkspace && typeof userContextId !== 'undefined' && this._workspaceCache?.workspaces) {
|
|
// Find all workspaces that match the given userContextId
|
|
const matchingWorkspaces = this._workspaceCache.workspaces.filter(
|
|
(workspace) => workspace.containerTabId === userContextId
|
|
);
|
|
|
|
// Check if exactly one workspace matches
|
|
if (matchingWorkspaces.length === 1) {
|
|
const workspace = matchingWorkspaces[0];
|
|
if (workspace.uuid !== this.getActiveWorkspaceFromCache().uuid) {
|
|
return [userContextId, true, workspace.uuid];
|
|
}
|
|
}
|
|
}
|
|
|
|
const activeWorkspace = this.getActiveWorkspaceFromCache();
|
|
const activeWorkspaceUserContextId = activeWorkspace?.containerTabId;
|
|
|
|
if (fromExternal !== true && typeof userContextId !== 'undefined' && userContextId !== activeWorkspaceUserContextId) {
|
|
return [userContextId, false, undefined];
|
|
}
|
|
return [activeWorkspaceUserContextId, true, undefined];
|
|
}
|
|
|
|
async shortcutSwitchTo(index) {
|
|
const workspaces = await this._workspaces();
|
|
// The index may be out of bounds, if it doesnt exist, don't do anything
|
|
if (index >= workspaces.workspaces.length || index < 0) {
|
|
return;
|
|
}
|
|
const workspaceToSwitch = workspaces.workspaces[index];
|
|
await this.changeWorkspace(workspaceToSwitch);
|
|
}
|
|
|
|
isBookmarkInAnotherWorkspace(bookmark) {
|
|
if (!this._workspaceBookmarksCache?.bookmarks) return false;
|
|
const bookmarkGuid = bookmark.bookmarkGuid;
|
|
const activeWorkspaceUuid = this.activeWorkspace;
|
|
let isInActiveWorkspace = false;
|
|
let isInOtherWorkspace = false;
|
|
|
|
for (const [workspaceUuid, bookmarkGuids] of Object.entries(this._workspaceBookmarksCache.bookmarks)) {
|
|
if (bookmarkGuids.includes(bookmarkGuid)) {
|
|
if (workspaceUuid === activeWorkspaceUuid) {
|
|
isInActiveWorkspace = true;
|
|
} else {
|
|
isInOtherWorkspace = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return true only if the bookmark is in another workspace and not in the active one
|
|
return isInOtherWorkspace && !isInActiveWorkspace;
|
|
}
|
|
|
|
// Session restore functions
|
|
get allStoredTabs() {
|
|
if (this._allStoredTabs) {
|
|
return this._allStoredTabs;
|
|
}
|
|
|
|
const tabs = [];
|
|
// we need to go through each tab in each container
|
|
const essentialsContainer = document.getElementById('zen-essentials-container');
|
|
let pinnedContainers = document.querySelectorAll('#vertical-pinned-tabs-container .zen-workspace-tabs-section');
|
|
let normalContainers = document.querySelectorAll('#tabbrowser-arrowscrollbox .zen-workspace-tabs-section');
|
|
if (!this._hasInitializedTabsStrip) {
|
|
pinnedContainers = [document.getElementById('vertical-pinned-tabs-container')];
|
|
normalContainers = [this.activeWorkspaceStrip];
|
|
}
|
|
const containers = [essentialsContainer, ...pinnedContainers, ...normalContainers];
|
|
for (const container of containers) {
|
|
for (const tab of container.children) {
|
|
if (tab.tagName === 'tab') {
|
|
tabs.push(tab);
|
|
const glance = tab.querySelector('.tabbrowser-tab[glance-id]');
|
|
if (glance) {
|
|
tabs.push(glance);
|
|
}
|
|
} else if (tab.tagName == 'tab-group') {
|
|
for (const groupTab of tab.tabs) {
|
|
tabs.push(groupTab);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
this._allStoredTabs = tabs;
|
|
return this._allStoredTabs;
|
|
}
|
|
|
|
get allTabGroups() {
|
|
if (!this._hasInitializedTabsStrip) {
|
|
let children = this.tabboxChildren;
|
|
return children.filter((node) => node.tagName == 'tab-group');
|
|
}
|
|
const pinnedContainers = document.querySelectorAll('#vertical-pinned-tabs-container .zen-workspace-tabs-section');
|
|
const normalContainers = document.querySelectorAll('#tabbrowser-arrowscrollbox .zen-workspace-tabs-section');
|
|
const containers = [...pinnedContainers, ...normalContainers];
|
|
const tabGroups = [];
|
|
for (const container of containers) {
|
|
for (const tabGroup of container.querySelectorAll('tab-group')) {
|
|
tabGroups.push(tabGroup);
|
|
}
|
|
}
|
|
return tabGroups;
|
|
}
|
|
|
|
get allUsedBrowsers() {
|
|
if (!this._hasInitializedTabsStrip) {
|
|
return gBrowser.browsers;
|
|
}
|
|
return Array.from(gBrowser.tabpanels.querySelectorAll('browser'));
|
|
}
|
|
|
|
get pinnedTabCount() {
|
|
return this.pinnedTabsContainer.children.length - 1;
|
|
}
|
|
|
|
get allWorkspaceTabs() {
|
|
const currentWorkspace = this.activeWorkspace;
|
|
return this.allStoredTabs.filter(
|
|
(tab) => tab.hasAttribute('zen-essential') || tab.getAttribute('zen-workspace-id') === currentWorkspace
|
|
);
|
|
}
|
|
|
|
reorganizeTabsAfterWelcome() {
|
|
const children = gBrowser.tabContainer.arrowScrollbox.children;
|
|
const remainingTabs = Array.from(children).filter((child) => child.tagName === 'tab');
|
|
for (const tab of remainingTabs) {
|
|
this.moveTabToWorkspace(tab, this.activeWorkspace);
|
|
}
|
|
}
|
|
|
|
async switchIfNeeded(browser, i) {
|
|
const tab = gBrowser.getTabForBrowser(browser);
|
|
await this.switchTabIfNeeded(tab);
|
|
}
|
|
|
|
async switchTabIfNeeded(tab) {
|
|
// Validate browser state first
|
|
if (!this._validateBrowserState()) {
|
|
console.warn('Browser state invalid for tab switching');
|
|
return;
|
|
}
|
|
|
|
if (!tab) {
|
|
console.warn('switchTabIfNeeded called with null tab');
|
|
return;
|
|
}
|
|
|
|
// Validate tab state
|
|
if (tab.closing || !tab.ownerGlobal || tab.ownerGlobal.closed || !tab.linkedBrowser) {
|
|
console.warn('Tab is no longer valid, cannot select it');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Check if we need to change workspace
|
|
if (!tab.hasAttribute('zen-essential') && tab.getAttribute('zen-workspace-id') !== this.activeWorkspace) {
|
|
// Use a mutex-like approach to prevent concurrent workspace changes
|
|
if (this._workspaceChangeInProgress) {
|
|
console.warn('Workspace change already in progress, deferring tab switch');
|
|
return;
|
|
}
|
|
|
|
this._workspaceChangeInProgress = true;
|
|
try {
|
|
await this.changeWorkspace({ uuid: tab.getAttribute('zen-workspace-id') });
|
|
} finally {
|
|
this._workspaceChangeInProgress = false;
|
|
}
|
|
}
|
|
|
|
// Safely switch to the tab using our debounced method
|
|
await this._safelySelectTab(tab);
|
|
} catch (e) {
|
|
console.error('Error in switchTabIfNeeded:', e);
|
|
}
|
|
}
|
|
|
|
getDefaultContainer() {
|
|
if (!this.workspaceEnabled) {
|
|
return 0;
|
|
}
|
|
const workspaces = this._workspaceCache;
|
|
if (!workspaces) {
|
|
return 0;
|
|
}
|
|
const activeWorkspace = this.activeWorkspace;
|
|
const workspace = workspaces.workspaces.find((workspace) => workspace.uuid === activeWorkspace);
|
|
return workspace.containerTabId;
|
|
}
|
|
|
|
onWindowResize(event = undefined) {
|
|
if (!(!event || event.target === window)) return;
|
|
// Check if workspace icons overflow the parent container
|
|
const parent = document.getElementById('zen-workspaces-button');
|
|
if (!parent || this._processingResize) {
|
|
return;
|
|
}
|
|
this._processingResize = true;
|
|
// Once we are overflowing, we align the buttons to always stay inside the container,
|
|
// meaning we need to remove the overflow attribute to reset the width
|
|
parent.removeAttribute('overflow');
|
|
requestAnimationFrame(() => {
|
|
const overflow = parent.scrollWidth > parent.clientWidth;
|
|
parent.toggleAttribute('overflow', overflow);
|
|
// The maximum width a button has when it overflows based on the number of buttons
|
|
const numButtons = parent.children.length + 1; // +1 to exclude the active button
|
|
const maxWidth = 100 / numButtons;
|
|
parent.style.setProperty('--zen-overflowed-workspace-button-width', `${maxWidth}%`);
|
|
this._processingResize = false;
|
|
|
|
// Scroll to the active workspace button if it's not visible
|
|
const activeButton = parent.querySelector('.zen-workspace-button.active');
|
|
if (!activeButton) {
|
|
return;
|
|
}
|
|
const parentRect = parent.getBoundingClientRect();
|
|
const activeRect = activeButton.getBoundingClientRect();
|
|
if (activeRect.left < parentRect.left || activeRect.right > parentRect.right) {
|
|
parent.scrollLeft = activeButton.offsetLeft;
|
|
}
|
|
});
|
|
}
|
|
})();
|