mirror of
https://github.com/zen-browser/desktop.git
synced 2026-08-10 00:38:04 +00:00
feat: Listen for more tab events and properly sync them, b=no-bug, c=common, folders, tabs
This commit is contained in:
@@ -47,7 +47,6 @@
|
||||
<script type="module" src="chrome://browser/content/zen-components/ZenFolder.mjs"></script>
|
||||
<script type="module" src="chrome://browser/content/zen-components/ZenCompactMode.mjs"></script>
|
||||
|
||||
<script type="module" src="chrome://browser/content/zen-components/ZenPinnedTabsStorage.mjs"></script>
|
||||
<script type="module" src="chrome://browser/content/zen-components/ZenWorkspacesStorage.mjs"></script>
|
||||
|
||||
<script type="module" src="chrome://browser/content/zen-components/ZenMediaController.mjs"></script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/browser/components/sessionstore/SessionStore.sys.mjs b/browser/components/sessionstore/SessionStore.sys.mjs
|
||||
index 1cdbc0f41bf5b55dfbbd850cb618c6d870f7a261..9ef1996a0e8a3ebe55dc25921b8fc8cc0ac8a303 100644
|
||||
index 1cdbc0f41bf5b55dfbbd850cb618c6d870f7a261..a7ae5953037bbdab8844c9e400caf233bd61eb0f 100644
|
||||
--- a/browser/components/sessionstore/SessionStore.sys.mjs
|
||||
+++ b/browser/components/sessionstore/SessionStore.sys.mjs
|
||||
@@ -127,6 +127,8 @@ const TAB_EVENTS = [
|
||||
@@ -72,7 +72,7 @@ index 1cdbc0f41bf5b55dfbbd850cb618c6d870f7a261..9ef1996a0e8a3ebe55dc25921b8fc8cc
|
||||
);
|
||||
tabState.pinned = false;
|
||||
+ tabState.zenEssential = false;
|
||||
+ tabState.zenPinnedId = null;
|
||||
+ tabState.zenSyncId = null;
|
||||
+ tabState.zenIsGlance = false;
|
||||
+ tabState.zenGlanceId = null;
|
||||
+ tabState.zenHasStaticLabel = false;
|
||||
@@ -172,7 +172,7 @@ index 1cdbc0f41bf5b55dfbbd850cb618c6d870f7a261..9ef1996a0e8a3ebe55dc25921b8fc8cc
|
||||
+ tab.setAttribute("zen-has-static-label", "true");
|
||||
+ }
|
||||
+ if (tabData.zenSyncId) {
|
||||
+ tab.setAttribute("id", tabData.zenPinnedId);
|
||||
+ tab.setAttribute("id", tabData.zenSyncId);
|
||||
+ }
|
||||
+ if (tabData.zenDefaultUserContextId) {
|
||||
+ tab.setAttribute("zenDefaultUserContextId", true);
|
||||
|
||||
@@ -17,8 +17,8 @@ class ZenSessionStore extends nsZenPreloadedFeature {
|
||||
if (tabData.zenWorkspace) {
|
||||
tab.setAttribute('zen-workspace-id', tabData.zenWorkspace);
|
||||
}
|
||||
if (tabData.zenPinnedId) {
|
||||
tab.setAttribute('id', tabData.zenPinnedId);
|
||||
if (tabData.zenSyncId) {
|
||||
tab.setAttribute('id', tabData.zenSyncId);
|
||||
}
|
||||
if (tabData.zenHasStaticLabel) {
|
||||
tab.setAttribute('zen-has-static-label', 'true');
|
||||
|
||||
@@ -811,7 +811,6 @@ window.gZenVerticalTabsManager = {
|
||||
!aItem.isConnected ||
|
||||
gZenUIManager.testingEnabled ||
|
||||
!gZenStartup.isReady ||
|
||||
!gZenPinnedTabManager.hasInitializedPins ||
|
||||
aItem.group?.hasAttribute('split-view-group')
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -945,8 +945,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
|
||||
if (gBrowser.isTabGroup(prevSibling)) {
|
||||
prevSiblingInfo = { type: 'group', id: prevSibling.id };
|
||||
} else if (gBrowser.isTab(prevSibling) && prevSibling.hasAttribute('id')) {
|
||||
const zenPinId = prevSibling.getAttribute('id');
|
||||
prevSiblingInfo = { type: 'tab', id: zenPinId };
|
||||
prevSiblingInfo = { type: 'tab', id: prevSibling.getAttribute('id') };
|
||||
} else {
|
||||
prevSiblingInfo = { type: 'start', id: null };
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ class nsZenSessionManager {
|
||||
let newWindow = Cu.cloneInto(windows[0], {});
|
||||
delete newWindow.selected;
|
||||
const newState = { windows: [newWindow] };
|
||||
SessionStoreInternal.restoreWindows(aWindow, newState, {});
|
||||
//SessionStoreInternal.restoreWindows(aWindow, newState, {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,17 +10,41 @@ ChromeUtils.defineESModuleGetters(lazy, {
|
||||
TabStateFlusher: 'resource:///modules/sessionstore/TabStateFlusher.sys.mjs',
|
||||
});
|
||||
|
||||
const OBSERVING = ['browser-window-delayed-startup'];
|
||||
const EVENTS = ['TabOpen'];
|
||||
const OBSERVING = ['browser-window-before-show'];
|
||||
const EVENTS = [
|
||||
'TabOpen',
|
||||
'ZenTabIconChanged',
|
||||
'ZenTabLabelChanged',
|
||||
'TabMove',
|
||||
'TabPinned',
|
||||
'TabUnpinned',
|
||||
'TabClose',
|
||||
'TabAddedToEssentials',
|
||||
'TabRemovedFromEssentials',
|
||||
];
|
||||
|
||||
// Flags acting as an enum for sync types.
|
||||
const SYNC_FLAG_LABEL = 1 << 0;
|
||||
const SYNC_FLAG_ICON = 1 << 1;
|
||||
const SYNC_FLAG_MOVE = 1 << 2;
|
||||
|
||||
class nsZenWindowSync {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Whether to ignore the next set of events.
|
||||
* This is used to prevent recursive event handling.
|
||||
* Context about the currently handled event.
|
||||
* Used to avoid re-entrancy issues.
|
||||
*
|
||||
* We do still wan't to keep a stack of these in order
|
||||
* to handle consequtive events properly. For example,
|
||||
* loading a webpage will call IconChanged and TitleChanged
|
||||
* events one after another.
|
||||
*/
|
||||
#ignoreNextEvents = false;
|
||||
#eventHandlingContext = {
|
||||
window: null,
|
||||
eventCount: 0,
|
||||
lastHandlerPromise: Promise.resolve(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Iterator that yields all currently opened browser windows.
|
||||
@@ -31,7 +55,7 @@ class nsZenWindowSync {
|
||||
#browserWindows = {
|
||||
*[Symbol.iterator]() {
|
||||
for (let window of lazy.BrowserWindowTracker.orderedWindows) {
|
||||
if (window.__SSi && !window.closed) {
|
||||
if (window.__SSi && !window.closed && window.gZenStartup.isReady) {
|
||||
yield window;
|
||||
}
|
||||
}
|
||||
@@ -42,7 +66,7 @@ class nsZenWindowSync {
|
||||
for (let topic of OBSERVING) {
|
||||
Services.obs.addObserver(this, topic);
|
||||
}
|
||||
SessionStore.promiseInitialized.then(() => {
|
||||
lazy.SessionStore.promiseAllWindowsRestored.then(() => {
|
||||
this.#onSessionStoreInitialized();
|
||||
});
|
||||
}
|
||||
@@ -106,18 +130,16 @@ class nsZenWindowSync {
|
||||
* @param {Function} aCallback - The callback function to run on each window.
|
||||
*/
|
||||
#runOnAllWindows(aWindow, aCallback) {
|
||||
this.#ignoreNextEvents = true;
|
||||
for (let window of this.#browserWindows) {
|
||||
if (window !== aWindow) {
|
||||
aCallback(window);
|
||||
}
|
||||
}
|
||||
this.#ignoreNextEvents = false;
|
||||
}
|
||||
|
||||
observe(aSubject, aTopic) {
|
||||
switch (aTopic) {
|
||||
case 'browser-window-delayed-startup': {
|
||||
case 'browser-window-before-show': {
|
||||
this.#onWindowBeforeShow(aSubject);
|
||||
break;
|
||||
}
|
||||
@@ -125,9 +147,41 @@ class nsZenWindowSync {
|
||||
}
|
||||
|
||||
handleEvent(aEvent) {
|
||||
if (this.#ignoreNextEvents) {
|
||||
const window = aEvent.currentTarget.ownerGlobal;
|
||||
if (!window.gZenStartup.isReady) {
|
||||
return;
|
||||
}
|
||||
if (this.#eventHandlingContext.window && this.#eventHandlingContext.window !== window) {
|
||||
// We're already handling an event for another window.
|
||||
// To avoid re-entrancy issues, we skip this event.
|
||||
return;
|
||||
}
|
||||
const lastHandlerPromise = this.#eventHandlingContext.lastHandlerPromise;
|
||||
this.#eventHandlingContext.eventCount++;
|
||||
this.#eventHandlingContext.window = window;
|
||||
let resolveNewPromise;
|
||||
this.#eventHandlingContext.lastHandlerPromise = new Promise((resolve) => {
|
||||
resolveNewPromise = resolve;
|
||||
});
|
||||
// Wait for the last handler to finish before processing the next event.
|
||||
lastHandlerPromise.then(() => {
|
||||
try {
|
||||
this.#handleNextEvent(aEvent);
|
||||
} finally {
|
||||
if (--this.#eventHandlingContext.eventCount === 0) {
|
||||
this.#eventHandlingContext.window = null;
|
||||
}
|
||||
resolveNewPromise();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the next event by calling the appropriate handler method.
|
||||
*
|
||||
* @param {Event} aEvent - The event to handle.
|
||||
*/
|
||||
#handleNextEvent(aEvent) {
|
||||
const handler = `on_${aEvent.type}`;
|
||||
if (typeof this[handler] === 'function') {
|
||||
this[handler](aEvent);
|
||||
@@ -136,18 +190,46 @@ class nsZenWindowSync {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a tab element from a window by its ID.
|
||||
*
|
||||
* @param {Window} aWindow - The window containing the tab.
|
||||
* @param {string} aTabId - The ID of the tab to retrieve.
|
||||
* @returns {Object|null} The tab element if found, otherwise null.
|
||||
*/
|
||||
#getTabFromWindow(aWindow, aTabId) {
|
||||
return aWindow.document.getElementById(aTabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes the icon and label of the target tab with the original tab.
|
||||
*
|
||||
* @param {Object} aOriginalTab - The original tab to copy from.
|
||||
* @param {Object} aTargetTab - The target tab to copy to.
|
||||
* @param {Window} aWindow - The window containing the tabs.
|
||||
* @param {number} flags - The sync flags indicating what to synchronize.
|
||||
*/
|
||||
#syncTabWithOriginal(aOriginalTab, aTargetTab, aWindow) {
|
||||
#syncTabWithOriginal(aOriginalTab, aTargetTab, aWindow, flags = 0) {
|
||||
if (!aOriginalTab || !aTargetTab) {
|
||||
return;
|
||||
}
|
||||
const { gBrowser } = aWindow;
|
||||
gBrowser.setIcon(aTargetTab, gBrowser.getIcon(aOriginalTab));
|
||||
gBrowser._setTabLabel(aTargetTab, aOriginalTab.label);
|
||||
this.#syncTabPosition(aOriginalTab, aTargetTab, aWindow);
|
||||
if (flags & SYNC_FLAG_ICON) {
|
||||
gBrowser.setIcon(aTargetTab, gBrowser.getIcon(aOriginalTab));
|
||||
}
|
||||
if (flags & SYNC_FLAG_LABEL) {
|
||||
gBrowser._setTabLabel(aTargetTab, aOriginalTab.label);
|
||||
}
|
||||
if (flags & SYNC_FLAG_MOVE && !aTargetTab.hasAttribute('zen-empty-tab')) {
|
||||
const workspaceId = aOriginalTab.getAttribute('zen-workspace-id');
|
||||
if (workspaceId) {
|
||||
aTargetTab.setAttribute('zen-workspace-id', workspaceId);
|
||||
} else {
|
||||
aTargetTab.removeAttribute('zen-workspace-id');
|
||||
}
|
||||
this.#syncTabPosition(aOriginalTab, aTargetTab, aWindow);
|
||||
}
|
||||
lazy.TabStateFlusher.flush(aTargetTab.linkedBrowser);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,15 +259,131 @@ class nsZenWindowSync {
|
||||
gBrowser.unpinTab(aTargetTab);
|
||||
}
|
||||
}
|
||||
|
||||
this.#moveTabToMatchOriginal(aOriginalTab, aTargetTab, aWindow, {
|
||||
isEssential: originalIsEssential,
|
||||
isPinned: originalIsPinned,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the target tab to match the position of the original tab.
|
||||
*
|
||||
* @param {Object} aOriginalTab - The original tab to match.
|
||||
* @param {Object} aTargetTab - The target tab to move.
|
||||
* @param {Window} aWindow - The window containing the tabs.
|
||||
*/
|
||||
#moveTabToMatchOriginal(aOriginalTab, aTargetTab, aWindow, { isEssential, isPinned }) {
|
||||
const { gBrowser, gZenWorkspaces } = aWindow;
|
||||
const originalSibling = aOriginalTab.previousElementSibling;
|
||||
let isFirstTab = true;
|
||||
if (gBrowser.isTabGroup(originalSibling) || gBrowser.isTab(originalSibling)) {
|
||||
isFirstTab = !originalSibling.hasAttribute('id');
|
||||
}
|
||||
|
||||
gBrowser.zenHandleTabMove(aOriginalTab, () => {
|
||||
if (isFirstTab) {
|
||||
let container;
|
||||
if (isEssential) {
|
||||
container = gZenWorkspaces.getEssentialsSection(aTargetTab);
|
||||
} else {
|
||||
const workspaceId = aTargetTab.getAttribute('zen-workspace-id');
|
||||
const workspaceElement = gZenWorkspaces.workspaceElement(workspaceId);
|
||||
container = isPinned
|
||||
? workspaceElement.pinnedTabsContainer
|
||||
: workspaceElement.tabsContainer;
|
||||
}
|
||||
if (container) {
|
||||
container.insertBefore(aTargetTab, container.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const relativeTab = this.#getTabFromWindow(aWindow, originalSibling.id);
|
||||
if (relativeTab) {
|
||||
relativeTab.after(aTargetTab);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes a tab across all browser windows.
|
||||
*
|
||||
* @param {Object} aTab - The tab to synchronize.
|
||||
* @param {number} flags - The sync flags indicating what to synchronize.
|
||||
*/
|
||||
#syncTabForAllWindows(aTab, flags = 0) {
|
||||
const window = aTab.ownerGlobal;
|
||||
this.#runOnAllWindows(window, (win) => {
|
||||
this.#syncTabWithOriginal(aTab, this.#getTabFromWindow(win, aTab.id), win, flags);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates generic sync events to synchronize tabs across windows.
|
||||
*
|
||||
* @param {Event} aEvent - The event to delegate.
|
||||
* @param {number} flags - The sync flags indicating what to synchronize.
|
||||
*/
|
||||
#delegateGenericSyncEvent(aEvent, flags = 0) {
|
||||
const tab = aEvent.target;
|
||||
this.#syncTabForAllWindows(tab, flags);
|
||||
}
|
||||
|
||||
/* Mark: Event Handlers */
|
||||
|
||||
on_TabOpen(aEvent) {
|
||||
const tab = aEvent.target;
|
||||
const window = tab.ownerGlobal;
|
||||
|
||||
tab.id = this.#newTabSyncId;
|
||||
this.#runOnAllWindows(window, (win) => {
|
||||
const newTab = win.gBrowser.duplicateTab(tab);
|
||||
this.#syncTabWithOriginal(tab, newTab, win);
|
||||
newTab.id = tab.id;
|
||||
this.#syncTabWithOriginal(
|
||||
tab,
|
||||
newTab,
|
||||
win,
|
||||
SYNC_FLAG_ICON | SYNC_FLAG_LABEL | SYNC_FLAG_MOVE
|
||||
);
|
||||
win.gZenVerticalTabsManager.animateItemOpen(newTab);
|
||||
});
|
||||
}
|
||||
|
||||
on_ZenTabIconChanged(aEvent) {
|
||||
return this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_ICON);
|
||||
}
|
||||
|
||||
on_ZenTabLabelChanged(aEvent) {
|
||||
return this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_LABEL);
|
||||
}
|
||||
|
||||
on_TabMove(aEvent) {
|
||||
return this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_MOVE);
|
||||
}
|
||||
|
||||
on_TabPinned(aEvent) {
|
||||
return this.on_TabMove(aEvent);
|
||||
}
|
||||
|
||||
on_TabUnpinned(aEvent) {
|
||||
return this.on_TabMove(aEvent);
|
||||
}
|
||||
|
||||
on_TabAddedToEssentials(aEvent) {
|
||||
return this.on_TabMove(aEvent);
|
||||
}
|
||||
|
||||
on_TabRemovedFromEssentials(aEvent) {
|
||||
return this.on_TabMove(aEvent);
|
||||
}
|
||||
|
||||
on_TabClose(aEvent) {
|
||||
const tab = aEvent.target;
|
||||
const window = tab.ownerGlobal;
|
||||
this.#runOnAllWindows(window, (win) => {
|
||||
const targetTab = this.#getTabFromWindow(win, tab.id);
|
||||
if (targetTab) {
|
||||
win.gBrowser.removeTab(targetTab, { animate: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ class ZenPinnedTabsObserver {
|
||||
}
|
||||
|
||||
class nsZenPinnedTabManager extends nsZenDOMOperatedFeature {
|
||||
hasInitializedPins = false;
|
||||
promiseInitializedPinned = new Promise((resolve) => {
|
||||
this._resolvePinnedInitializedInternal = resolve;
|
||||
});
|
||||
@@ -87,7 +86,6 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature {
|
||||
}
|
||||
|
||||
onTabIconChanged(tab, url = null) {
|
||||
tab.dispatchEvent(new CustomEvent('ZenTabIconChanged', { bubbles: true, detail: { tab } }));
|
||||
tab.dispatchEvent(new CustomEvent('ZenTabIconChanged', { bubbles: true, detail: { tab } }));
|
||||
const iconUrl = url ?? tab.iconImage.src;
|
||||
if (tab.hasAttribute('zen-essential')) {
|
||||
@@ -949,7 +947,7 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature {
|
||||
}
|
||||
|
||||
async onTabLabelChanged(tab) {
|
||||
tab.dispatchEvent(new CustomEvent('ZenTabLabelChanged', { detail: { tab } }));
|
||||
tab.dispatchEvent(new CustomEvent('ZenTabLabelChanged', { bubbles: true, detail: { tab } }));
|
||||
if (!this._pinsCache) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,635 +0,0 @@
|
||||
// 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 ZenPinnedTabsStorage = {
|
||||
async init() {
|
||||
await this._ensureTable();
|
||||
},
|
||||
|
||||
async _ensureTable() {
|
||||
await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage._ensureTable', async (db) => {
|
||||
// Create the pins table if it doesn't exist
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS zen_pins (
|
||||
id INTEGER PRIMARY KEY,
|
||||
uuid TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT,
|
||||
container_id INTEGER,
|
||||
workspace_uuid TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
is_essential BOOLEAN NOT NULL DEFAULT 0,
|
||||
is_group BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
const columns = await db.execute(`PRAGMA table_info(zen_pins)`);
|
||||
const columnNames = columns.map((row) => row.getResultByName('name'));
|
||||
|
||||
// Helper function to add column if it doesn't exist
|
||||
const addColumnIfNotExists = async (columnName, definition) => {
|
||||
if (!columnNames.includes(columnName)) {
|
||||
await db.execute(`ALTER TABLE zen_pins ADD COLUMN ${columnName} ${definition}`);
|
||||
}
|
||||
};
|
||||
|
||||
await addColumnIfNotExists('edited_title', 'BOOLEAN NOT NULL DEFAULT 0');
|
||||
await addColumnIfNotExists('is_folder_collapsed', 'BOOLEAN NOT NULL DEFAULT 0');
|
||||
await addColumnIfNotExists('folder_icon', 'TEXT DEFAULT NULL');
|
||||
await addColumnIfNotExists('folder_parent_uuid', 'TEXT DEFAULT NULL');
|
||||
|
||||
await db.execute(`
|
||||
CREATE INDEX IF NOT EXISTS idx_zen_pins_uuid ON zen_pins(uuid)
|
||||
`);
|
||||
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS zen_pins_changes (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
timestamp INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await db.execute(`
|
||||
CREATE INDEX IF NOT EXISTS idx_zen_pins_changes_uuid ON zen_pins_changes(uuid)
|
||||
`);
|
||||
|
||||
this._resolveInitialized();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Private helper method to notify observers with a list of changed UUIDs.
|
||||
* @param {string} event - The observer event name.
|
||||
* @param {Array<string>} uuids - Array of changed workspace UUIDs.
|
||||
*/
|
||||
_notifyPinsChanged(event, uuids) {
|
||||
if (uuids.length === 0) return; // No changes to notify
|
||||
|
||||
// Convert the array of UUIDs to a JSON string
|
||||
const data = JSON.stringify(uuids);
|
||||
|
||||
Services.obs.notifyObservers(null, event, data);
|
||||
},
|
||||
|
||||
async savePin(pin, notifyObservers = true) {
|
||||
const changedUUIDs = new Set();
|
||||
|
||||
await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.savePin', async (db) => {
|
||||
await db.executeTransaction(async () => {
|
||||
const now = Date.now();
|
||||
|
||||
let newPosition;
|
||||
if ('position' in pin && Number.isFinite(pin.position)) {
|
||||
newPosition = pin.position;
|
||||
} else {
|
||||
// Get the maximum position within the same parent group (or null for root level)
|
||||
const maxPositionResult = await db.execute(
|
||||
`
|
||||
SELECT MAX("position") as max_position
|
||||
FROM zen_pins
|
||||
WHERE COALESCE(folder_parent_uuid, '') = COALESCE(:folder_parent_uuid, '')
|
||||
`,
|
||||
{ folder_parent_uuid: pin.parentUuid || null }
|
||||
);
|
||||
const maxPosition = maxPositionResult[0].getResultByName('max_position') || 0;
|
||||
newPosition = maxPosition + 1000;
|
||||
}
|
||||
|
||||
// Insert or replace the pin
|
||||
await db.executeCached(
|
||||
`
|
||||
INSERT OR REPLACE INTO zen_pins (
|
||||
uuid, title, url, container_id, workspace_uuid, position,
|
||||
is_essential, is_group, folder_parent_uuid, edited_title, created_at,
|
||||
updated_at, is_folder_collapsed, folder_icon
|
||||
) VALUES (
|
||||
:uuid, :title, :url, :container_id, :workspace_uuid, :position,
|
||||
:is_essential, :is_group, :folder_parent_uuid, :edited_title,
|
||||
COALESCE((SELECT created_at FROM zen_pins WHERE uuid = :uuid), :now),
|
||||
:now, :is_folder_collapsed, :folder_icon
|
||||
)
|
||||
`,
|
||||
{
|
||||
uuid: pin.uuid,
|
||||
title: pin.title,
|
||||
url: pin.isGroup ? '' : pin.url,
|
||||
container_id: pin.containerTabId || null,
|
||||
workspace_uuid: pin.workspaceUuid || null,
|
||||
position: newPosition,
|
||||
is_essential: pin.isEssential || false,
|
||||
is_group: pin.isGroup || false,
|
||||
folder_parent_uuid: pin.parentUuid || null,
|
||||
edited_title: pin.editedTitle || false,
|
||||
now,
|
||||
folder_icon: pin.folderIcon || null,
|
||||
is_folder_collapsed: pin.isFolderCollapsed || false,
|
||||
}
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
`
|
||||
INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp)
|
||||
VALUES (:uuid, :timestamp)
|
||||
`,
|
||||
{
|
||||
uuid: pin.uuid,
|
||||
timestamp: Math.floor(now / 1000),
|
||||
}
|
||||
);
|
||||
|
||||
changedUUIDs.add(pin.uuid);
|
||||
await this.updateLastChangeTimestamp(db);
|
||||
});
|
||||
});
|
||||
|
||||
if (notifyObservers) {
|
||||
this._notifyPinsChanged('zen-pin-updated', Array.from(changedUUIDs));
|
||||
}
|
||||
},
|
||||
|
||||
async getPins() {
|
||||
const db = await PlacesUtils.promiseDBConnection();
|
||||
const rows = await db.executeCached(`
|
||||
SELECT * FROM zen_pins
|
||||
ORDER BY position ASC
|
||||
`);
|
||||
return rows.map((row) => ({
|
||||
uuid: row.getResultByName('uuid'),
|
||||
title: row.getResultByName('title'),
|
||||
url: row.getResultByName('url'),
|
||||
containerTabId: row.getResultByName('container_id'),
|
||||
workspaceUuid: row.getResultByName('workspace_uuid'),
|
||||
position: row.getResultByName('position'),
|
||||
isEssential: Boolean(row.getResultByName('is_essential')),
|
||||
isGroup: Boolean(row.getResultByName('is_group')),
|
||||
parentUuid: row.getResultByName('folder_parent_uuid'),
|
||||
editedTitle: Boolean(row.getResultByName('edited_title')),
|
||||
folderIcon: row.getResultByName('folder_icon'),
|
||||
isFolderCollapsed: Boolean(row.getResultByName('is_folder_collapsed')),
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new group
|
||||
* @param {string} title - The title of the group
|
||||
* @param {string} workspaceUuid - The workspace UUID (optional)
|
||||
* @param {string} parentUuid - The parent group UUID (optional, null for root level)
|
||||
* @param {number} position - The position of the group (optional, will auto-calculate if not provided)
|
||||
* @param {boolean} notifyObservers - Whether to notify observers (default: true)
|
||||
* @returns {Promise<string>} The UUID of the created group
|
||||
*/
|
||||
async createGroup(
|
||||
title,
|
||||
icon = null,
|
||||
isCollapsed = false,
|
||||
workspaceUuid = null,
|
||||
parentUuid = null,
|
||||
position = null,
|
||||
notifyObservers = true
|
||||
) {
|
||||
if (!title || typeof title !== 'string') {
|
||||
throw new Error('Group title is required and must be a string');
|
||||
}
|
||||
|
||||
const groupUuid = gZenUIManager.generateUuidv4();
|
||||
|
||||
const groupPin = {
|
||||
uuid: groupUuid,
|
||||
title,
|
||||
folderIcon: icon || null,
|
||||
isFolderCollapsed: isCollapsed || false,
|
||||
workspaceUuid,
|
||||
parentUuid,
|
||||
position,
|
||||
isGroup: true,
|
||||
isEssential: false,
|
||||
editedTitle: true, // Group titles are always considered edited
|
||||
};
|
||||
|
||||
await this.savePin(groupPin, notifyObservers);
|
||||
return groupUuid;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add an existing tab/pin to a group
|
||||
* @param {string} tabUuid - The UUID of the tab to add to the group
|
||||
* @param {string} groupUuid - The UUID of the target group
|
||||
* @param {number} position - The position within the group (optional, will append if not provided)
|
||||
* @param {boolean} notifyObservers - Whether to notify observers (default: true)
|
||||
*/
|
||||
async addTabToGroup(tabUuid, groupUuid, position = null, notifyObservers = true) {
|
||||
if (!tabUuid || !groupUuid) {
|
||||
throw new Error('Both tabUuid and groupUuid are required');
|
||||
}
|
||||
|
||||
const changedUUIDs = new Set();
|
||||
|
||||
await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.addTabToGroup', async (db) => {
|
||||
await db.executeTransaction(async () => {
|
||||
// Verify the group exists and is actually a group
|
||||
const groupCheck = await db.execute(
|
||||
`SELECT is_group FROM zen_pins WHERE uuid = :groupUuid`,
|
||||
{ groupUuid }
|
||||
);
|
||||
|
||||
if (groupCheck.length === 0) {
|
||||
throw new Error(`Group with UUID ${groupUuid} does not exist`);
|
||||
}
|
||||
|
||||
if (!groupCheck[0].getResultByName('is_group')) {
|
||||
throw new Error(`Pin with UUID ${groupUuid} is not a group`);
|
||||
}
|
||||
|
||||
const tabCheck = await db.execute(`SELECT uuid FROM zen_pins WHERE uuid = :tabUuid`, {
|
||||
tabUuid,
|
||||
});
|
||||
|
||||
if (tabCheck.length === 0) {
|
||||
throw new Error(`Tab with UUID ${tabUuid} does not exist`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let newPosition;
|
||||
|
||||
if (position !== null && Number.isFinite(position)) {
|
||||
newPosition = position;
|
||||
} else {
|
||||
// Get the maximum position within the group
|
||||
const maxPositionResult = await db.execute(
|
||||
`SELECT MAX("position") as max_position FROM zen_pins WHERE folder_parent_uuid = :groupUuid`,
|
||||
{ groupUuid }
|
||||
);
|
||||
const maxPosition = maxPositionResult[0].getResultByName('max_position') || 0;
|
||||
newPosition = maxPosition + 1000;
|
||||
}
|
||||
|
||||
await db.execute(
|
||||
`
|
||||
UPDATE zen_pins
|
||||
SET folder_parent_uuid = :groupUuid,
|
||||
position = :newPosition,
|
||||
updated_at = :now
|
||||
WHERE uuid = :tabUuid
|
||||
`,
|
||||
{
|
||||
tabUuid,
|
||||
groupUuid,
|
||||
newPosition,
|
||||
now,
|
||||
}
|
||||
);
|
||||
|
||||
changedUUIDs.add(tabUuid);
|
||||
|
||||
await db.execute(
|
||||
`
|
||||
INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp)
|
||||
VALUES (:uuid, :timestamp)
|
||||
`,
|
||||
{
|
||||
uuid: tabUuid,
|
||||
timestamp: Math.floor(now / 1000),
|
||||
}
|
||||
);
|
||||
|
||||
await this.updateLastChangeTimestamp(db);
|
||||
});
|
||||
});
|
||||
|
||||
if (notifyObservers) {
|
||||
this._notifyPinsChanged('zen-pin-updated', Array.from(changedUUIDs));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a tab from its group (move to root level)
|
||||
* @param {string} tabUuid - The UUID of the tab to remove from its group
|
||||
* @param {number} newPosition - The new position at root level (optional, will append if not provided)
|
||||
* @param {boolean} notifyObservers - Whether to notify observers (default: true)
|
||||
*/
|
||||
async removeTabFromGroup(tabUuid, newPosition = null, notifyObservers = true) {
|
||||
if (!tabUuid) {
|
||||
throw new Error('tabUuid is required');
|
||||
}
|
||||
|
||||
const changedUUIDs = new Set();
|
||||
|
||||
await PlacesUtils.withConnectionWrapper(
|
||||
'ZenPinnedTabsStorage.removeTabFromGroup',
|
||||
async (db) => {
|
||||
await db.executeTransaction(async () => {
|
||||
// Verify the tab exists and is in a group
|
||||
const tabCheck = await db.execute(
|
||||
`SELECT folder_parent_uuid FROM zen_pins WHERE uuid = :tabUuid`,
|
||||
{ tabUuid }
|
||||
);
|
||||
|
||||
if (tabCheck.length === 0) {
|
||||
throw new Error(`Tab with UUID ${tabUuid} does not exist`);
|
||||
}
|
||||
|
||||
if (!tabCheck[0].getResultByName('folder_parent_uuid')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let finalPosition;
|
||||
|
||||
if (newPosition !== null && Number.isFinite(newPosition)) {
|
||||
finalPosition = newPosition;
|
||||
} else {
|
||||
// Get the maximum position at root level (where folder_parent_uuid is null)
|
||||
const maxPositionResult = await db.execute(
|
||||
`SELECT MAX("position") as max_position FROM zen_pins WHERE folder_parent_uuid IS NULL`
|
||||
);
|
||||
const maxPosition = maxPositionResult[0].getResultByName('max_position') || 0;
|
||||
finalPosition = maxPosition + 1000;
|
||||
}
|
||||
|
||||
// Update the tab to be at root level
|
||||
await db.execute(
|
||||
`
|
||||
UPDATE zen_pins
|
||||
SET folder_parent_uuid = NULL,
|
||||
position = :newPosition,
|
||||
updated_at = :now
|
||||
WHERE uuid = :tabUuid
|
||||
`,
|
||||
{
|
||||
tabUuid,
|
||||
newPosition: finalPosition,
|
||||
now,
|
||||
}
|
||||
);
|
||||
|
||||
changedUUIDs.add(tabUuid);
|
||||
|
||||
// Record the change
|
||||
await db.execute(
|
||||
`
|
||||
INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp)
|
||||
VALUES (:uuid, :timestamp)
|
||||
`,
|
||||
{
|
||||
uuid: tabUuid,
|
||||
timestamp: Math.floor(now / 1000),
|
||||
}
|
||||
);
|
||||
|
||||
await this.updateLastChangeTimestamp(db);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
if (notifyObservers) {
|
||||
this._notifyPinsChanged('zen-pin-updated', Array.from(changedUUIDs));
|
||||
}
|
||||
},
|
||||
|
||||
async removePin(uuid, notifyObservers = true) {
|
||||
const changedUUIDs = [uuid];
|
||||
|
||||
await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.removePin', async (db) => {
|
||||
await db.executeTransaction(async () => {
|
||||
// Get all child UUIDs first for change tracking
|
||||
const children = await db.execute(
|
||||
`SELECT uuid FROM zen_pins WHERE folder_parent_uuid = :uuid`,
|
||||
{
|
||||
uuid,
|
||||
}
|
||||
);
|
||||
|
||||
// Add child UUIDs to changedUUIDs array
|
||||
for (const child of children) {
|
||||
changedUUIDs.push(child.getResultByName('uuid'));
|
||||
}
|
||||
|
||||
// Delete the pin/group itself
|
||||
await db.execute(`DELETE FROM zen_pins WHERE uuid = :uuid`, { uuid });
|
||||
|
||||
// Record the changes
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const changedUuid of changedUUIDs) {
|
||||
await db.execute(
|
||||
`
|
||||
INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp)
|
||||
VALUES (:uuid, :timestamp)
|
||||
`,
|
||||
{
|
||||
uuid: changedUuid,
|
||||
timestamp: now,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await this.updateLastChangeTimestamp(db);
|
||||
});
|
||||
});
|
||||
|
||||
if (notifyObservers) {
|
||||
this._notifyPinsChanged('zen-pin-removed', changedUUIDs);
|
||||
}
|
||||
},
|
||||
|
||||
async wipeAllPins() {
|
||||
await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.wipeAllPins', async (db) => {
|
||||
await db.execute(`DELETE FROM zen_pins`);
|
||||
await db.execute(`DELETE FROM zen_pins_changes`);
|
||||
await this.updateLastChangeTimestamp(db);
|
||||
});
|
||||
},
|
||||
|
||||
async markChanged(uuid) {
|
||||
await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.markChanged', async (db) => {
|
||||
const now = Date.now();
|
||||
await db.execute(
|
||||
`
|
||||
INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp)
|
||||
VALUES (:uuid, :timestamp)
|
||||
`,
|
||||
{
|
||||
uuid,
|
||||
timestamp: Math.floor(now / 1000),
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async getChangedIDs() {
|
||||
const db = await PlacesUtils.promiseDBConnection();
|
||||
const rows = await db.execute(`
|
||||
SELECT uuid, timestamp FROM zen_pins_changes
|
||||
`);
|
||||
const changes = {};
|
||||
for (const row of rows) {
|
||||
changes[row.getResultByName('uuid')] = row.getResultByName('timestamp');
|
||||
}
|
||||
return changes;
|
||||
},
|
||||
|
||||
async clearChangedIDs() {
|
||||
await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.clearChangedIDs', async (db) => {
|
||||
await db.execute(`DELETE FROM zen_pins_changes`);
|
||||
});
|
||||
},
|
||||
|
||||
shouldReorderPins(before, current, after) {
|
||||
const minGap = 1; // Minimum allowed gap between positions
|
||||
return (
|
||||
(before !== null && current - before < minGap) || (after !== null && after - current < minGap)
|
||||
);
|
||||
},
|
||||
|
||||
async reorderAllPins(db, changedUUIDs) {
|
||||
const pins = await db.execute(`
|
||||
SELECT uuid
|
||||
FROM zen_pins
|
||||
ORDER BY position ASC
|
||||
`);
|
||||
|
||||
for (let i = 0; i < pins.length; i++) {
|
||||
const newPosition = (i + 1) * 1000; // Use large increments
|
||||
await db.execute(
|
||||
`
|
||||
UPDATE zen_pins
|
||||
SET position = :newPosition
|
||||
WHERE uuid = :uuid
|
||||
`,
|
||||
{ newPosition, uuid: pins[i].getResultByName('uuid') }
|
||||
);
|
||||
changedUUIDs.add(pins[i].getResultByName('uuid'));
|
||||
}
|
||||
},
|
||||
|
||||
async updateLastChangeTimestamp(db) {
|
||||
const now = Date.now();
|
||||
await db.execute(
|
||||
`
|
||||
INSERT OR REPLACE INTO moz_meta (key, value)
|
||||
VALUES ('zen_pins_last_change', :now)
|
||||
`,
|
||||
{ now }
|
||||
);
|
||||
},
|
||||
|
||||
async getLastChangeTimestamp() {
|
||||
const db = await PlacesUtils.promiseDBConnection();
|
||||
const result = await db.executeCached(`
|
||||
SELECT value FROM moz_meta WHERE key = 'zen_pins_last_change'
|
||||
`);
|
||||
return result.length ? parseInt(result[0].getResultByName('value'), 10) : 0;
|
||||
},
|
||||
|
||||
async updatePinPositions(pins) {
|
||||
const changedUUIDs = new Set();
|
||||
|
||||
await PlacesUtils.withConnectionWrapper(
|
||||
'ZenPinnedTabsStorage.updatePinPositions',
|
||||
async (db) => {
|
||||
await db.executeTransaction(async () => {
|
||||
const now = Date.now();
|
||||
|
||||
for (let i = 0; i < pins.length; i++) {
|
||||
const pin = pins[i];
|
||||
const newPosition = (i + 1) * 1000;
|
||||
|
||||
await db.execute(
|
||||
`
|
||||
UPDATE zen_pins
|
||||
SET position = :newPosition
|
||||
WHERE uuid = :uuid
|
||||
`,
|
||||
{ newPosition, uuid: pin.uuid }
|
||||
);
|
||||
|
||||
changedUUIDs.add(pin.uuid);
|
||||
|
||||
// Record the change
|
||||
await db.execute(
|
||||
`
|
||||
INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp)
|
||||
VALUES (:uuid, :timestamp)
|
||||
`,
|
||||
{
|
||||
uuid: pin.uuid,
|
||||
timestamp: Math.floor(now / 1000),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await this.updateLastChangeTimestamp(db);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this._notifyPinsChanged('zen-pin-updated', Array.from(changedUUIDs));
|
||||
},
|
||||
|
||||
async updatePinTitle(uuid, newTitle, isEdited = true, notifyObservers = true) {
|
||||
if (!uuid || typeof newTitle !== 'string') {
|
||||
throw new Error('Invalid parameters: uuid and newTitle are required');
|
||||
}
|
||||
|
||||
const changedUUIDs = new Set();
|
||||
|
||||
await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.updatePinTitle', async (db) => {
|
||||
await db.executeTransaction(async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// Update the pin's title and edited_title flag
|
||||
const result = await db.execute(
|
||||
`
|
||||
UPDATE zen_pins
|
||||
SET title = :newTitle,
|
||||
edited_title = :isEdited,
|
||||
updated_at = :now
|
||||
WHERE uuid = :uuid
|
||||
`,
|
||||
{
|
||||
uuid,
|
||||
newTitle,
|
||||
isEdited,
|
||||
now,
|
||||
}
|
||||
);
|
||||
|
||||
// Only proceed with change tracking if a row was actually updated
|
||||
if (result.rowsAffected > 0) {
|
||||
changedUUIDs.add(uuid);
|
||||
|
||||
// Record the change
|
||||
await db.execute(
|
||||
`
|
||||
INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp)
|
||||
VALUES (:uuid, :timestamp)
|
||||
`,
|
||||
{
|
||||
uuid,
|
||||
timestamp: Math.floor(now / 1000),
|
||||
}
|
||||
);
|
||||
|
||||
await this.updateLastChangeTimestamp(db);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (notifyObservers && changedUUIDs.size > 0) {
|
||||
this._notifyPinsChanged('zen-pin-updated', Array.from(changedUUIDs));
|
||||
}
|
||||
},
|
||||
|
||||
async __dropTables() {
|
||||
await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.__dropTables', async (db) => {
|
||||
await db.execute(`DROP TABLE IF EXISTS zen_pins`);
|
||||
await db.execute(`DROP TABLE IF EXISTS zen_pins_changes`);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
ZenPinnedTabsStorage.promiseInitialized = new Promise((resolve) => {
|
||||
ZenPinnedTabsStorage._resolveInitialized = resolve;
|
||||
ZenPinnedTabsStorage.init();
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
content/browser/zen-components/ZenPinnedTabsStorage.mjs (../../zen/tabs/ZenPinnedTabsStorage.mjs)
|
||||
content/browser/zen-components/ZenPinnedTabManager.mjs (../../zen/tabs/ZenPinnedTabManager.mjs)
|
||||
* content/browser/zen-styles/zen-tabs.css (../../zen/tabs/zen-tabs.css)
|
||||
content/browser/zen-styles/zen-tabs/vertical-tabs.css (../../zen/tabs/zen-tabs/vertical-tabs.css)
|
||||
Reference in New Issue
Block a user