feat: add performance controls for large browser ops

- Add throttled large-operation handling for tab, group, and session commands.
- Introduce performance profiles, audible-tab aware gentle mode, and job progress tracking.
- Support background session restores with status/cancel commands and lazy placeholders.
- Expose new perf and extension CLI groups plus matching Python SDK methods.
- Preserve pinned tabs during session snapshots and debounce auto-save updates.
- Bump browser-cli and extension versions to 0.10.0 and add pytest-cov to dev deps.
- Add coverage for performance controls, background jobs, lazy restores, and tab metadata.
This commit is contained in:
2026-05-20 22:13:57 +02:00
parent e1e4adbb25
commit 545abeb515
18 changed files with 1054 additions and 148 deletions
+164 -38
View File
@@ -1,14 +1,14 @@
// @ts-nocheck
import { getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor } from '../core';
export async function sessionSave({ name }) {
const tabs = await chrome.tabs.query({});
const groups = await chrome.tabGroups.query({});
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
function buildSessionSnapshot(tabs, groups) {
const groupById = new Map(groups.map(group => [group.id, group]));
const sessionTabs = tabs
.filter(tab => Boolean(tab.url))
return tabs
.filter(tab => Boolean(tab.url || tab.pendingUrl))
.sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index))
.map(tab => {
const entry = { url: tab.url };
const entry = { url: tab.url || tab.pendingUrl };
if (tab.pinned) entry.pinned = true;
if (tab.groupId >= 0) {
const group = groupById.get(tab.groupId);
entry.group = {
@@ -20,49 +20,110 @@ export async function sessionSave({ name }) {
}
return entry;
});
}
function sessionSignature(sessionTabs) {
return JSON.stringify(sessionTabs.map(tab => ({
url: tab.url,
pinned: Boolean(tab.pinned),
group: tab.group ? {
key: tab.group.key || "",
title: tab.group.title || "",
color: normalizeGroupColor(tab.group.color),
collapsed: Boolean(tab.group.collapsed),
} : null,
})));
}
export async function sessionSave({ name }) {
const tabs = await chrome.tabs.query({});
const groups = await chrome.tabGroups.query({});
const sessionTabs = buildSessionSnapshot(tabs, groups);
const signature = sessionSignature(sessionTabs);
const sessions = await getSessions();
sessions[name] = {
tabs: sessionTabs,
urls: sessionTabs.map(tab => tab.url),
savedAt: Date.now(),
signature,
};
await chrome.storage.local.set({ sessions });
return { name, tabs: sessionTabs.length };
}
export async function sessionLoad({ name }) {
const sessions = await getSessions();
const session = sessions[name];
if (!session) throw new Error(`Session '${name}' not found`);
function lazyPlaceholderUrl(url) {
const escaped = String(url).replace(/[&<>"']/g, ch => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[ch]));
const html = `<!doctype html><title>Lazy tab</title><body style="font-family:sans-serif;padding:2rem"><h1>Lazy tab</h1><p>This tab will load when selected.</p><p><a href="${escaped}">${escaped}</a></p></body>`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
const sessionTabs = getSessionTabs(session);
const createdTabs = [];
export async function activateLazyTab(tabId) {
const { lazySessionTabs } = await chrome.storage.local.get("lazySessionTabs");
const entry = lazySessionTabs?.[tabId];
if (!entry?.url) return false;
delete lazySessionTabs[tabId];
await chrome.storage.local.set({ lazySessionTabs });
await chrome.tabs.update(Number(tabId), { url: entry.url });
return true;
}
for (const entry of sessionTabs) {
const tab = await chrome.tabs.create({ url: entry.url, active: false });
createdTabs.push({ tabId: tab.id, entry });
}
export async function sessionLoad({ name, gentleMode, discardBackgroundTabs = false, lazy = false, eagerTabs = 10, __job } = {}) {
return runLargeOperation("session.load", async () => {
const sessions = await getSessions();
const session = sessions[name];
if (!session) throw new Error(`Session '${name}' not found`);
const groups = new Map();
for (const { tabId, entry } of createdTabs) {
if (!entry.group) continue;
const key = entry.group.key || `${entry.group.title || "group"}:${groups.size}`;
if (!groups.has(key)) {
groups.set(key, { meta: entry.group, tabIds: [] });
const sessionTabs = getSessionTabs(session).sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)));
const createdTabs = [];
const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode);
const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length;
const { lazySessionTabs } = await chrome.storage.local.get("lazySessionTabs");
const lazyMap = lazySessionTabs || {};
updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length });
for (const [idx, entry] of sessionTabs.entries()) {
throwIfJobCancelled(__job);
const shouldLazy = lazy && idx >= eagerLimit;
const tab = await chrome.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) });
createdTabs.push({ tabId: tab.id, entry });
if (shouldLazy) {
lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() };
} else if (discardBackgroundTabs && !entry.pinned && chrome.tabs.discard) {
try { await chrome.tabs.discard(tab.id); } catch (_) {}
}
updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length });
await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs));
}
groups.get(key).tabIds.push(tabId);
}
if (lazy) await chrome.storage.local.set({ lazySessionTabs: lazyMap });
for (const { meta, tabIds } of groups.values()) {
const restoredGroupId = await chrome.tabs.group({ tabIds });
await chrome.tabGroups.update(restoredGroupId, {
title: meta.title || "",
color: normalizeGroupColor(meta.color),
collapsed: Boolean(meta.collapsed),
});
}
const groups = new Map();
for (const { tabId, entry } of createdTabs) {
if (!entry.group) continue;
const key = entry.group.key || `${entry.group.title || "group"}:${groups.size}`;
if (!groups.has(key)) {
groups.set(key, { meta: entry.group, tabIds: [] });
}
groups.get(key).tabIds.push(tabId);
}
return { name, tabs: sessionTabs.length };
let restoredGroups = 0;
updateJobProgress(__job, { phase: "restoring groups", current: 0, total: groups.size });
for (const { meta, tabIds } of groups.values()) {
throwIfJobCancelled(__job);
const restoredGroupId = await chrome.tabs.group({ tabIds });
await chrome.tabGroups.update(restoredGroupId, {
title: meta.title || "",
color: normalizeGroupColor(meta.color),
collapsed: Boolean(meta.collapsed),
});
restoredGroups++;
updateJobProgress(__job, { phase: "restoring groups", current: restoredGroups, total: groups.size });
await yieldForLargeOperation(restoredGroups, 5, Math.max(50, throttle.pauseMs));
}
return { name, tabs: sessionTabs.length, gentle: throttle.gentle, audible: throttle.audible, discarded: Boolean(discardBackgroundTabs), lazy: Boolean(lazy), eagerTabs: eagerLimit };
});
}
export async function sessionList() {
@@ -92,21 +153,86 @@ export async function sessionDiff({ nameA, nameB }) {
};
}
let autoSaveTimer = null;
let autoSaveInFlight = false;
let autoSavePending = false;
export async function sessionAutoSave({ enabled }) {
await chrome.storage.local.set({ autoSave: enabled });
chrome.tabs.onUpdated.removeListener(autoSaveHandler);
chrome.tabs.onCreated.removeListener(autoSaveHandler);
chrome.tabs.onRemoved.removeListener(autoSaveHandler);
chrome.tabs.onMoved.removeListener(autoSaveHandler);
chrome.tabs.onAttached.removeListener(autoSaveHandler);
chrome.tabs.onDetached.removeListener(autoSaveHandler);
chrome.tabs.onUpdated.removeListener(autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.removeListener(autoSaveHandler);
if (autoSaveTimer) clearTimeout(autoSaveTimer);
autoSaveTimer = null;
autoSavePending = false;
if (enabled) {
chrome.tabs.onUpdated.addListener(autoSaveHandler);
chrome.tabs.onCreated.addListener(autoSaveHandler);
chrome.tabs.onRemoved.addListener(autoSaveHandler);
chrome.tabs.onMoved.addListener(autoSaveHandler);
chrome.tabs.onAttached.addListener(autoSaveHandler);
chrome.tabs.onDetached.addListener(autoSaveHandler);
chrome.tabs.onUpdated.addListener(autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(autoSaveHandler);
}
return { enabled };
}
export async function autoSaveHandler() {
async function saveAutoSessionIfChanged() {
const tabs = await chrome.tabs.query({});
const groups = await chrome.tabGroups.query({});
const sessionTabs = buildSessionSnapshot(tabs, groups);
const signature = sessionSignature(sessionTabs);
const { autoSaveSignature } = await chrome.storage.local.get("autoSaveSignature");
if (autoSaveSignature === signature) return { skipped: true, tabs: sessionTabs.length };
const sessions = await getSessions();
sessions.__auto__ = {
tabs: sessionTabs,
urls: sessionTabs.map(tab => tab.url),
savedAt: Date.now(),
signature,
};
await chrome.storage.local.set({ sessions, autoSaveSignature: signature });
return { skipped: false, tabs: sessionTabs.length };
}
async function runAutoSave() {
if (autoSaveInFlight) {
autoSavePending = true;
return;
}
autoSaveInFlight = true;
try {
const { autoSave } = await chrome.storage.local.get("autoSave");
if (autoSave) await runLargeOperation("session.auto_save", saveAutoSessionIfChanged);
} finally {
autoSaveInFlight = false;
if (autoSavePending) {
autoSavePending = false;
autoSaveTimer = setTimeout(runAutoSave, 1000);
}
}
}
async function scheduleAutoSave(delayMs = 1000) {
const { autoSave } = await chrome.storage.local.get("autoSave");
if (!autoSave) return;
await sessionSave({ name: "__auto__" });
if (autoSaveTimer) clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(runAutoSave, delayMs);
}
export async function autoSaveHandler() {
await scheduleAutoSave();
}
export async function autoSaveUpdatedHandler(_tabId, changeInfo = {}) {
// Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure.
if (!("url" in changeInfo)) return;
await scheduleAutoSave();
}
// ── Misc ──────────────────────────────────────────────────────────────────────