components/src/ZenWorkspacesStorage.mjs
Kristijan Ribarić 8ca8157e61 feat: Improve workspace ordering and changes tracking
This commit introduces improvements to workspace ordering and change tracking in the Zen Workspaces system:

- **Workspace Ordering:**
    - Uses `REAL` data type for `position` in `zen_workspaces` table, allowing for more precise and efficient ordering.
    - Introduces a new `updateWorkspaceOrder` method to update workspace positions.
    - Reorders workspaces with large increments to avoid frequent reordering, ensuring consistent ordering after changes.
    - Implements a mechanism to check for necessary reordering after position updates and reorder all workspaces if required.
- **Changes Tracking:**
    - Tracks changes to workspaces by inserting a record in `zen_workspaces_changes` for each modified workspace.
    - Adds an index on `uuid` column in `zen_workspaces_changes` table for faster querying.
    - Updates `getLastChangeTimestamp` method to return the last change timestamp from the changes tracking table, providing accurate timestamp for changes.

These changes optimize workspace management, improve accuracy of ordering, and enhance the performance of change tracking.
2024-10-09 19:38:06 +02:00

360 lines
12 KiB
TypeScript

var ZenWorkspacesStorage = {
async init() {
console.log('ZenWorkspacesStorage: Initializing...');
await this._ensureTable();
},
async _ensureTable() {
await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage._ensureTable', async (db) => {
// Create the main workspaces table if it doesn't exist
await db.execute(`
CREATE TABLE IF NOT EXISTS zen_workspaces (
id INTEGER PRIMARY KEY,
uuid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
icon TEXT,
is_default INTEGER NOT NULL DEFAULT 0,
container_id INTEGER,
position REAL 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_workspaces_uuid ON zen_workspaces(uuid)
`);
// Create the changes tracking table if it doesn't exist
await db.execute(`
CREATE TABLE IF NOT EXISTS zen_workspaces_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_workspaces_changes_uuid ON zen_workspaces_changes(uuid)
`);
});
},
async migrateWorkspacesFromJSON() {
const oldWorkspacesPath = PathUtils.join(PathUtils.profileDir, 'zen-workspaces', 'Workspaces.json');
if (await IOUtils.exists(oldWorkspacesPath)) {
console.info('ZenWorkspacesStorage: Migrating workspaces from JSON...');
const oldWorkspaces = await IOUtils.readJSON(oldWorkspacesPath);
if (oldWorkspaces.workspaces) {
for (const workspace of oldWorkspaces.workspaces) {
await this.saveWorkspace(workspace);
}
}
await IOUtils.remove(oldWorkspacesPath);
}
},
/**
* 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.
*/
_notifyWorkspacesChanged(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 saveWorkspace(workspace, notifyObservers = true) {
const changedUUIDs = new Set();
await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.saveWorkspace', async (db) => {
await db.executeTransaction(async () => {
const now = Date.now();
// Handle default workspace
if (workspace.default) {
await db.execute(`UPDATE zen_workspaces SET is_default = 0 WHERE uuid != :uuid`, { uuid: workspace.uuid });
const unsetDefaultRows = await db.execute(`SELECT uuid FROM zen_workspaces WHERE is_default = 0 AND uuid != :uuid`, { uuid: workspace.uuid });
for (const row of unsetDefaultRows) {
changedUUIDs.add(row.getResultByName('uuid'));
}
}
let newPosition;
if ('position' in workspace && Number.isFinite(workspace.position)) {
newPosition = workspace.position;
} else {
// Get the maximum position
const maxPositionResult = await db.execute(`SELECT MAX("position") as max_position FROM zen_workspaces`);
const maxPosition = maxPositionResult[0].getResultByName('max_position') || 0;
newPosition = maxPosition + 1000; // Add a large increment to avoid frequent reordering
}
// Insert or replace the workspace
await db.executeCached(`
INSERT OR REPLACE INTO zen_workspaces (
uuid, name, icon, is_default, container_id, created_at, updated_at, "position"
) VALUES (
:uuid, :name, :icon, :is_default, :container_id,
COALESCE((SELECT created_at FROM zen_workspaces WHERE uuid = :uuid), :now),
:now,
:position
)
`, {
uuid: workspace.uuid,
name: workspace.name,
icon: workspace.icon || null,
is_default: workspace.default ? 1 : 0,
container_id: workspace.containerTabId || null,
now,
position: newPosition
});
// Record the change
await db.execute(`
INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp)
VALUES (:uuid, :timestamp)
`, {
uuid: workspace.uuid,
timestamp: Math.floor(now / 1000)
});
changedUUIDs.add(workspace.uuid);
await this.updateLastChangeTimestamp(db);
});
});
if (notifyObservers) {
this._notifyWorkspacesChanged("zen-workspace-updated", Array.from(changedUUIDs));
}
},
async getWorkspaces() {
const db = await PlacesUtils.promiseDBConnection();
const rows = await db.executeCached(`
SELECT * FROM zen_workspaces ORDER BY created_at ASC
`);
return rows.map((row) => ({
uuid: row.getResultByName('uuid'),
name: row.getResultByName('name'),
icon: row.getResultByName('icon'),
default: !!row.getResultByName('is_default'),
containerTabId: row.getResultByName('container_id'),
position: row.getResultByName('position'),
}));
},
async removeWorkspace(uuid, notifyObservers = true) {
const changedUUIDs = [uuid];
await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.removeWorkspace', async (db) => {
await db.execute(
`
DELETE FROM zen_workspaces WHERE uuid = :uuid
`,
{ uuid }
);
// Record the removal as a change
const now = Date.now();
await db.execute(`
INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp)
VALUES (:uuid, :timestamp)
`, {
uuid,
timestamp: Math.floor(now / 1000)
});
await this.updateLastChangeTimestamp(db);
});
if (notifyObservers) {
this._notifyWorkspacesChanged("zen-workspace-removed", changedUUIDs);
}
},
async wipeAllWorkspaces() {
await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.wipeAllWorkspaces', async (db) => {
await db.execute(`DELETE FROM zen_workspaces`);
await db.execute(`DELETE FROM zen_workspaces_changes`);
await this.updateLastChangeTimestamp(db);
});
},
async setDefaultWorkspace(uuid, notifyObservers = true) {
const changedUUIDs = [];
await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.setDefaultWorkspace', async (db) => {
await db.executeTransaction(async () => {
const now = Date.now();
// Unset the default flag for all other workspaces
await db.execute(`UPDATE zen_workspaces SET is_default = 0 WHERE uuid != :uuid`, { uuid });
// Collect UUIDs of workspaces that were unset as default
const unsetDefaultRows = await db.execute(`SELECT uuid FROM zen_workspaces WHERE is_default = 0 AND uuid != :uuid`, { uuid });
for (const row of unsetDefaultRows) {
changedUUIDs.push(row.getResultByName('uuid'));
}
// Set the default flag for the specified workspace
await db.execute(`UPDATE zen_workspaces SET is_default = 1 WHERE uuid = :uuid`, { uuid });
// Record the change for the specified workspace
await db.execute(`
INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp)
VALUES (:uuid, :timestamp)
`, {
uuid,
timestamp: Math.floor(now / 1000)
});
// Add the main workspace UUID to the changed set
changedUUIDs.push(uuid);
await this.updateLastChangeTimestamp(db);
});
});
if (notifyObservers) {
this._notifyWorkspacesChanged("zen-workspace-updated", changedUUIDs);
}
},
async markChanged(uuid) {
await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.markChanged', async (db) => {
const now = Date.now();
await db.execute(`
INSERT OR REPLACE INTO zen_workspaces_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_workspaces_changes
`);
const changes = {};
for (const row of rows) {
changes[row.getResultByName('uuid')] = row.getResultByName('timestamp');
}
return changes;
},
async clearChangedIDs() {
await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.clearChangedIDs', async (db) => {
await db.execute(`DELETE FROM zen_workspaces_changes`);
});
},
async updateWorkspaceOrder(uuid, newPosition, notifyObservers = true) {
const changedUUIDs = new Set([uuid]);
await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.updateWorkspaceOrder', async (db) => {
await db.executeTransaction(async () => {
const now = Date.now();
// Get the positions of the workspaces before and after the new position
const neighborPositions = await db.execute(`
SELECT
(SELECT MAX("position") FROM zen_workspaces WHERE "position" < :newPosition) as before_position,
(SELECT MIN("position") FROM zen_workspaces WHERE "position" > :newPosition) as after_position
`, { newPosition });
let beforePosition = neighborPositions[0].getResultByName('before_position');
let afterPosition = neighborPositions[0].getResultByName('after_position');
// Calculate the new position
if (beforePosition === null && afterPosition === null) {
// This is the only workspace
newPosition = 1000;
} else if (beforePosition === null) {
// This will be the first workspace
newPosition = afterPosition / 2;
} else if (afterPosition === null) {
// This will be the last workspace
newPosition = beforePosition + 1000;
} else {
// This workspace will be between two others
newPosition = (beforePosition + afterPosition) / 2;
}
// Update the workspace's position
await db.execute(`
UPDATE zen_workspaces
SET "position" = :newPosition
WHERE uuid = :uuid
`, { uuid, newPosition });
// Record the change
await db.execute(`
INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp)
VALUES (:uuid, :timestamp)
`, {
uuid,
timestamp: Math.floor(now / 1000)
});
await this.updateLastChangeTimestamp(db);
// Check if reordering is necessary
if (this.shouldReorderWorkspaces(beforePosition, newPosition, afterPosition)) {
await this.reorderAllWorkspaces(db, changedUUIDs);
}
});
});
if (notifyObservers) {
this._notifyWorkspacesChanged("zen-workspace-updated", Array.from(changedUUIDs));
}
},
shouldReorderWorkspaces(before, current, after) {
const minGap = 0.001; // Minimum allowed gap between positions
return (before !== null && current - before < minGap) || (after !== null && after - current < minGap);
},
async reorderAllWorkspaces(db, changedUUIDs) {
const workspaces = await db.execute(`
SELECT uuid
FROM zen_workspaces
ORDER BY "position" ASC
`);
for (let i = 0; i < workspaces.length; i++) {
const newPosition = (i + 1) * 1000; // Use large increments
await db.execute(`
UPDATE zen_workspaces
SET "position" = :newPosition
WHERE uuid = :uuid
`, { newPosition, uuid: workspaces[i].getResultByName('uuid') });
changedUUIDs.add(workspaces[i].getResultByName('uuid'));
}
},
async updateLastChangeTimestamp(db) {
const now = Date.now();
await db.execute(`
INSERT OR REPLACE INTO moz_meta (key, value)
VALUES ('zen_workspaces_last_change', :now)
`, { now });
},
async getLastChangeTimestamp() {
const db = await PlacesUtils.promiseDBConnection();
const result = await db.executeCached(`
SELECT value FROM moz_meta WHERE key = 'zen_workspaces_last_change'
`);
return result.length ? parseInt(result[0].getResultByName('value'), 10) : 0;
},
};