diff --git a/src/ZenPinnedTabManager.mjs b/src/ZenPinnedTabManager.mjs
index c69e133..a6e2008 100644
--- a/src/ZenPinnedTabManager.mjs
+++ b/src/ZenPinnedTabManager.mjs
@@ -36,21 +36,135 @@
}
}
- class ZenPinnedTabManager extends ZenPreloadedFeature {
- init() {
+ class ZenPinnedTabManager extends ZenMultiWindowFeature {
+
+ async init() {
this.observer = new ZenPinnedTabsObserver();
+ await ZenPinnedTabsStorage.init();
+ // TODO: Figure out if this is even needed - for now it's commented out
+ // await SessionStore.promiseInitialized;
+ await this._refreshPinnedTabs();
this._initClosePinnedTabShortcut();
this._insertItemsIntoTabContextMenu();
this.observer.addPinnedTabListener(this._onPinnedTabEvent.bind(this));
}
+ async _refreshPinnedTabs() {
+ await this._initializePinsCache();
+ this._initializePinnedTabs();
+ }
+
+ async _initializePinsCache() {
+ try {
+ // Get pin data
+ const pins = await ZenPinnedTabsStorage.getPins();
+
+ // Enhance pins with favicons
+ const enhancedPins = await Promise.all(pins.map(async pin => {
+ try {
+ const faviconData = await PlacesUtils.promiseFaviconData(pin.url);
+ return {
+ ...pin,
+ iconUrl: faviconData?.uri?.spec || null
+ };
+ } catch(ex) {
+ // If favicon fetch fails, continue without icon
+ return {
+ ...pin,
+ iconUrl: null
+ };
+ }
+ }));
+
+ this._pinsCache = enhancedPins.sort((a, b) => {
+ if (!a.workspaceUuid && b.workspaceUuid) return -1;
+ if (a.workspaceUuid && !b.workspaceUuid) return 1;
+ return 0;
+ });
+
+ } catch (ex) {
+ console.error("Failed to initialize pins cache:", ex);
+ this._pinsCache = [];
+ }
+
+ return this._pinsCache;
+ }
+
+ _initializePinnedTabs() {
+ const pins = this._pinsCache;
+ if (!pins?.length) {
+ // If there are no pins, we should remove any existing pinned tabs
+ for (let tab of gBrowser.tabs) {
+ if (tab.pinned && tab.getAttribute("zen-pin-id")) {
+ gBrowser.removeTab(tab);
+ }
+ }
+ return;
+ }
+
+ const activeTab = gBrowser.selectedTab;
+ const pinnedTabsByUUID = new Map();
+ const pinsToCreate = new Set(pins.map(p => p.uuid));
+
+ // First pass: identify existing tabs and remove those without pins
+ for (let tab of gBrowser.tabs) {
+ const pinId = tab.getAttribute("zen-pin-id");
+ if (!pinId) {
+ continue;
+ }
+
+ if (pinsToCreate.has(pinId)) {
+ // This is a valid pinned tab that matches a pin
+ pinnedTabsByUUID.set(pinId, tab);
+ pinsToCreate.delete(pinId);
+ } else {
+ // This is a pinned tab that no longer has a corresponding pin
+ gBrowser.removeTab(tab);
+ }
+ }
+
+ // Second pass: create new tabs for pins that don't have tabs
+ for (let pin of pins) {
+ if (!pinsToCreate.has(pin.uuid)) {
+ continue; // Skip pins that already have tabs
+ }
+
+ let newTab = gBrowser.addTrustedTab(pin.url, {
+ skipAnimation: true,
+ userContextId: pin.containerTabId || 0,
+ allowInheritPrincipal: false,
+ createLazyBrowser: true,
+ skipLoad: true,
+ });
+
+ // Set the favicon from cache
+ if (pin.iconUrl) {
+ gBrowser.setIcon(newTab, pin.iconUrl, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ }
+
+ newTab.setAttribute("zen-pin-id", pin.uuid);
+
+ if (pin.workspaceUuid) {
+ newTab.setAttribute("zen-workspace-id", pin.workspaceUuid);
+ }
+
+ gBrowser.pinTab(newTab);
+ }
+
+ // Restore active tab
+ if (!activeTab.closing) {
+ gBrowser.selectedTab = activeTab;
+ }
+ }
+
_onPinnedTabEvent(action, event) {
const tab = event.target;
switch (action) {
case "TabPinned":
- this._setPinnedAttributes(tab);
tab._zenClickEventListener = this._onTabClick.bind(this, tab);
tab.addEventListener("click", tab._zenClickEventListener);
+ this._setPinnedAttributes(tab);
break;
case "TabUnpinned":
this._removePinnedAttributes(tab);
@@ -71,7 +185,7 @@
}
}
- resetPinnedTab(tab) {
+ async resetPinnedTab(tab) {
if (!tab) {
tab = TabContextMenu.contextTab;
@@ -81,33 +195,65 @@
return;
}
- this._resetTabToStoredState(tab);
+ await this._resetTabToStoredState(tab);
}
- replacePinnedUrlWithCurrent() {
+ async replacePinnedUrlWithCurrent() {
const tab = TabContextMenu.contextTab;
- if (!tab || !tab.pinned) {
+ if (!tab || !tab.pinned || !tab.getAttribute("zen-pin-id")) {
return;
}
- this._setPinnedAttributes(tab);
- }
-
- _setPinnedAttributes(tab) {
const browser = tab.linkedBrowser;
- const entry = {
- url: browser.currentURI.spec,
- title: tab.label || browser.contentTitle,
- triggeringPrincipal_base64: lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL
- };
- tab.setAttribute("zen-pinned-entry", JSON.stringify(entry));
- tab.setAttribute("zen-pinned-icon", browser.mIconURL);
+ const pin = this._pinsCache.find(pin => pin.uuid === tab.getAttribute("zen-pin-id"));
+
+ if (!pin) {
+ return;
+ }
+
+ pin.title = tab.label || browser.contentTitle;
+ pin.url = browser.currentURI.spec;
+ pin.workspaceUuid = tab.getAttribute("zen-workspace-id");
+ pin.userContextId = tab.getAttribute("userContextId");
+
+ await ZenPinnedTabsStorage.savePin(pin);
+ await this._refreshPinnedTabs();
}
- _removePinnedAttributes(tab) {
- tab.removeAttribute("zen-pinned-entry");
- tab.removeAttribute("zen-pinned-icon");
+ async _setPinnedAttributes(tab) {
+ const browser = tab.linkedBrowser;
+
+ const uuid = gZenUIManager.generateUuidv4();
+
+ await ZenPinnedTabsStorage.savePin({
+ uuid,
+ title: tab.label || browser.contentTitle,
+ url: browser.currentURI.spec,
+ containerTabId: tab.getAttribute("userContextId"),
+ workspaceUuid: tab.getAttribute("zen-workspace-id")
+ });
+
+ tab.setAttribute("zen-pin-id", uuid);
+
+ await this._refreshPinnedTabs();
+ }
+
+ async _removePinnedAttributes(tab) {
+ if(!tab.getAttribute("zen-pin-id")) {
+ return;
+ }
+
+ await ZenPinnedTabsStorage.removePin(tab.getAttribute("zen-pin-id"));
+
+ tab.removeAttribute("zen-pin-id");
+
+ if(!tab.hasAttribute("zen-workspace-id") && ZenWorkspaces.workspaceEnabled) {
+ const workspace = await ZenWorkspaces.getActiveWorkspace();
+ tab.setAttribute("zen-workspace-id", workspace.uuid);
+ }
+
+ await this._refreshPinnedTabs();
}
_initClosePinnedTabShortcut() {
@@ -119,20 +265,14 @@
}
setPinnedTabState(tabData, tab) {
- tabData.zenPinnedEntry = tab.getAttribute("zen-pinned-entry");
- tabData.zenPinnedIcon = tab.getAttribute("zen-pinned-icon");
+ tabData.zenPinId = tab.getAttribute("zen-pin-id");
}
updatePinnedTabForSessionRestore(tabData, tab) {
- if (tabData.zenPinnedEntry) {
- tab.setAttribute("zen-pinned-entry", tabData.zenPinnedEntry);
+ if (tabData.zenPinId) {
+ tab.setAttribute("zen-pin-id", tabData.zenPinId);
} else {
- tab.removeAttribute("zen-pinned-entry");
- }
- if (tabData.zenPinnedIcon) {
- tab.setAttribute("zen-pinned-icon", tabData.zenPinnedIcon);
- } else {
- tab.removeAttribute("zen-pinned-icon");
+ tab.removeAttribute("zen-pin-id");
}
}
@@ -197,15 +337,25 @@
}
}
- _resetTabToStoredState(tab) {
- const entry = tab.getAttribute("zen-pinned-entry");
- const icon = tab.getAttribute("zen-pinned-icon");
+ async _resetTabToStoredState(tab) {
+ const id = tab.getAttribute("zen-pin-id");
- if (entry) {
+ if (!id) {
+ return;
+ }
+
+ const pin = this._pinsCache.find(pin => pin.uuid === id);
+
+ if (pin) {
const tabState = SessionStore.getTabState(tab);
const state = JSON.parse(tabState);
+ const icon = await PlacesUtils.promiseFaviconData(pin.url);
- state.entries = [JSON.parse(entry)];
+ state.entries = [{
+ url: pin.url,
+ title: pin.title,
+ triggeringPrincipal_base64: lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL
+ }];
state.image = icon;
state.index = 0;
@@ -213,6 +363,17 @@
}
}
+ _addGlobalPin() {
+ const tab = TabContextMenu.contextTab;
+ if (!tab || tab.pinned) {
+ return;
+ }
+
+ tab.removeAttribute("zen-workspace-id");
+
+ gBrowser.pinTab(tab);
+ }
+
_insertItemsIntoTabContextMenu() {
const elements = window.MozXULElement.parseXULToFragment(`
`);
document.getElementById('tabContextMenu').appendChild(elements);
+
+ const element = window.MozXULElement.parseXULToFragment(`
+
+ `);
+
+ document.getElementById('context_pinTab').after(element);
}
resetPinnedTabData(tabData) {
@@ -237,8 +407,9 @@
updatePinnedTabContextMenu(contextTab) {
const isVisible = contextTab.pinned && !contextTab.multiselected;
- document.getElementById("context_zen-reset-pinned-tab").hidden = !isVisible || !contextTab.getAttribute("zen-pinned-entry");
+ document.getElementById("context_zen-reset-pinned-tab").hidden = !isVisible || !contextTab.getAttribute("zen-pin-id");
document.getElementById("context_zen-replace-pinned-url-with-current").hidden = !isVisible;
+ document.getElementById("context_zen-pin-tab-global").hidden = contextTab.pinned;
}
}
diff --git a/src/ZenPinnedTabsStorage.mjs b/src/ZenPinnedTabsStorage.mjs
new file mode 100644
index 0000000..09043bf
--- /dev/null
+++ b/src/ZenPinnedTabsStorage.mjs
@@ -0,0 +1,270 @@
+var ZenPinnedTabsStorage = {
+ async init() {
+ console.log('ZenPinnedTabsStorage: Initializing...');
+ 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 NOT NULL,
+ container_id INTEGER,
+ workspace_uuid TEXT,
+ position INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ )
+ `);
+
+ // Create an index on the uuid column
+ await db.execute(`
+ CREATE INDEX IF NOT EXISTS idx_zen_pins_uuid ON zen_pins(uuid)
+ `);
+
+ // Create the changes tracking table if it doesn't exist
+ await db.execute(`
+ CREATE TABLE IF NOT EXISTS zen_pins_changes (
+ uuid TEXT PRIMARY KEY,
+ timestamp INTEGER NOT NULL
+ )
+ `);
+
+ // Create an index on the uuid column for changes tracking table
+ await db.execute(`
+ CREATE INDEX IF NOT EXISTS idx_zen_pins_changes_uuid ON zen_pins_changes(uuid)
+ `);
+ });
+ },
+
+ /**
+ * Private helper method to notify observers with a list of changed UUIDs.
+ * @param {string} event - The observer event name.
+ * @param {Array} 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
+ const maxPositionResult = await db.execute(`SELECT MAX("position") as max_position FROM zen_pins`);
+ const maxPosition = maxPositionResult[0].getResultByName('max_position') || 0;
+ newPosition = maxPosition + 1000; // Add a large increment to avoid frequent reordering
+ }
+
+ // Insert or replace the pin
+ await db.executeCached(`
+ INSERT OR REPLACE INTO zen_pins (
+ uuid, title, url, container_id, workspace_uuid, position,
+ created_at, updated_at
+ ) VALUES (
+ :uuid, :title, :url, :container_id, :workspace_uuid, :position,
+ COALESCE((SELECT created_at FROM zen_pins WHERE uuid = :uuid), :now),
+ :now
+ )
+ `, {
+ uuid: pin.uuid,
+ title: pin.title,
+ url: pin.url,
+ container_id: pin.containerTabId || null,
+ workspace_uuid: pin.workspaceUuid || null,
+ position: newPosition,
+ now
+ });
+
+ // 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)
+ });
+
+ 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')
+ }));
+ },
+
+ async removePin(uuid, notifyObservers = true) {
+ const changedUUIDs = [uuid];
+
+ await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.removePin', async (db) => {
+ await db.execute(
+ `DELETE FROM zen_pins WHERE uuid = :uuid`,
+ { uuid }
+ );
+
+ // Record the removal as a change
+ 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)
+ });
+
+ 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));
+ }
+};
diff --git a/src/ZenWorkspaces.mjs b/src/ZenWorkspaces.mjs
index d4fa786..b30bc58 100644
--- a/src/ZenWorkspaces.mjs
+++ b/src/ZenWorkspaces.mjs
@@ -810,7 +810,7 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature {
document.documentElement.setAttribute('zen-workspace-id', window.uuid);
let tabCount = 0;
for (let tab of gBrowser.tabs) {
- if (!tab.hasAttribute('zen-workspace-id')) {
+ if (!tab.hasAttribute('zen-workspace-id') && !tab.pinned) {
tab.setAttribute('zen-workspace-id', window.uuid);
tabCount++;
}
@@ -879,10 +879,6 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature {
button.removeAttribute('disabled');
}
- get _shouldAllowPinTab() {
- return Services.prefs.getBoolPref('zen.workspaces.individual-pinned-tabs');
- }
-
addChangeListeners(func) {
if (!this._changeListeners) {
this._changeListeners = [];
@@ -898,13 +894,10 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature {
this._inChangingWorkspace = true;
this.activeWorkspace = window.uuid;
- const shouldAllowPinnedTabs = this._shouldAllowPinTab;
this.tabContainer._invalidateCachedTabs();
let firstTab = undefined;
for (let tab of gBrowser.tabs) {
- if (
- (tab.getAttribute('zen-workspace-id') === window.uuid && !(tab.pinned && !shouldAllowPinnedTabs)) ||
- !tab.hasAttribute('zen-workspace-id')
+ if (tab.getAttribute('zen-workspace-id') === window.uuid || !tab.hasAttribute('zen-workspace-id')
) {
if (!firstTab && (onInit || !tab.pinned)) {
firstTab = tab;
@@ -913,7 +906,7 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature {
firstTab = null; // note: Do not add "undefined" here, a new tab would be created
}
gBrowser.showTab(tab);
- if (!tab.hasAttribute('zen-workspace-id')) {
+ if (!tab.hasAttribute('zen-workspace-id') && !tab.pinned) {
// We add the id to those tabs that got inserted before we initialize the workspaces
// example use case: opening a link from an external app
tab.setAttribute('zen-workspace-id', window.uuid);
@@ -927,8 +920,8 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature {
this._createNewTabForWorkspace(window);
}
for (let tab of gBrowser.tabs) {
- if (tab.getAttribute('zen-workspace-id') !== window.uuid) {
- gBrowser.hideTab(tab, undefined, shouldAllowPinnedTabs);
+ if (tab.getAttribute('zen-workspace-id') !== window.uuid && !(tab.pinned && !tab.hasAttribute('zen-workspace-id'))) {
+ gBrowser.hideTab(tab, undefined, true);
}
}
this.tabContainer._invalidateCachedTabs();
@@ -1020,7 +1013,7 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature {
const parent = browser.ownerGlobal;
let tab = gBrowser.getTabForBrowser(browser);
let workspaceID = tab.getAttribute('zen-workspace-id');
- if (!workspaceID || (tab.pinned && !this._shouldAllowPinTab)) {
+ if (!workspaceID || tab.pinned) {
return;
}
let activeWorkspace = await parent.ZenWorkspaces.getActiveWorkspace();