refactor(extension): class-based command registry + modular src layout
Restructure the MV3 background worker from a monolithic core.ts/index.ts
into a class-based command architecture. Behavior is identical — the 83
registered commands dispatch byte-for-byte the same as before.
Structure
- One class per command group, each extending CommandGroup and exporting a
`commands` map keyed by the full command id ("tabs.close"). Groups:
Navigation, TabsMutation, TabsQuery, Groups, Windows, Dom (dom/extract/
page), BrowserData (storage/cookies), Session (session/clients + autosave
+ lazy-tab activation), Perf (perf + jobs.status/cancel), Extension.
- CommandRegistry merges the group maps (throws on duplicate ids), routes
background specs to JobManager and paginates array results via
makePagedData. JobManager owns the job map + lifecycle. NativeConnection
owns the native-port lifecycle and the inbound message router.
- index.ts is now thin wiring: JobManager -> ctx -> assembleRegistry ->
onActivated -> NativeConnection.start().
- Infra classes live in classes/ (PascalCase, file = class name); command
groups in commands/; shared helpers split out of core.ts into core/
(errors, throttle, scripting, tab-helpers, group-helpers, storage); all
types moved into types/ (json, jobs, session, tabs, messages,
command-args) behind a barrel.
DRY cleanup
- resolveTabUrl(tabId) and assertScriptableUrl(url, action) collapse the
tab/URL-guard boilerplate duplicated across dom.ts and browser-data.ts.
- processInBatches() centralizes the throttled, cancellable batch loop
shared by tabs.close, group.close and tabs.merge_windows.
- captureCurrentSession() dedups the snapshot-and-signature block shared by
session.save and the autosave path.
- DomArgs type alias replaces 21 inline ContentArgs & { tabId? } copies.
- Drop fetchTabHtml's redundant retry loop (executeScript already retries
transient frame/tab errors), a dead tabInfo import, and two stale
comments referencing a removed asArgs helper.
Type safety & tests
- Full noImplicitAny; no `any`/`unknown` annotations remain in src.
- JS unit-test harness using node --test + node:assert (zero new deps),
bundled via the existing esbuild. Covers JobManager retention/lifecycle
and the autosave listener-wiring/debounce with an in-memory chrome mock.
- The structural pytest checks track the new file homes and the centralized
processInBatches helper.
Verification: npm run check:extension green (tsc + esbuild 84.5kb +
node --check + 18 JS tests); uv run pytest -q -> 409 passed, 105 skipped.
No version bump.
This commit is contained in:
+129
-234
@@ -1,255 +1,150 @@
|
||||
// @ts-nocheck
|
||||
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import { AutoSaveManager } from './autosave';
|
||||
import { captureCurrentSession } from './session-snapshot';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { LazySessionMap, SessionSaveArgs, SessionRemoveArgs, SessionDiffArgs, SessionLoadArgs, SessionAutoSaveArgs, ClientsRenameProfileArgs } from '../types';
|
||||
|
||||
function buildSessionSnapshot(tabs, groups) {
|
||||
const groupById = new Map(groups.map(group => [group.id, group]));
|
||||
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 || tab.pendingUrl };
|
||||
if (tab.pinned) entry.pinned = true;
|
||||
if (tab.groupId >= 0) {
|
||||
const group = groupById.get(tab.groupId);
|
||||
entry.group = {
|
||||
key: `${tab.windowId}:${tab.groupId}`,
|
||||
title: group?.title || "",
|
||||
color: normalizeGroupColor(group?.color),
|
||||
collapsed: Boolean(group?.collapsed),
|
||||
};
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
function lazyPlaceholderUrl(url) {
|
||||
function lazyPlaceholderUrl(url: string) {
|
||||
const escaped = String(url).replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[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)}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
export class SessionCommands extends CommandGroup {
|
||||
readonly namespace = "session";
|
||||
readonly commands: Record<string, CommandEntry> = {
|
||||
"session.save": (a: SessionSaveArgs) => this.sessionSave(a),
|
||||
"session.load": { background: true, run: (a: SessionLoadArgs) => this.sessionLoad(a) },
|
||||
"session.list": () => this.sessionList(),
|
||||
"session.remove": (a: SessionRemoveArgs) => this.sessionRemove(a),
|
||||
"session.diff": (a: SessionDiffArgs) => this.sessionDiff(a),
|
||||
"session.auto_save": (a: SessionAutoSaveArgs) => this.autoSaveManager.setEnabled(Boolean(a.enabled)),
|
||||
"clients.list": () => this.clientsList(),
|
||||
"clients.rename_profile": (a: ClientsRenameProfileArgs) => this.clientsRenameProfile(a),
|
||||
};
|
||||
|
||||
export async function sessionLoad({ name, gentleMode, discardBackgroundTabs = false, lazy = false, eagerTabs = 10, __job } = {}) {
|
||||
return runLargeOperation("session.load", async () => {
|
||||
private readonly autoSaveManager = new AutoSaveManager();
|
||||
|
||||
private async sessionSave({ name }: SessionSaveArgs) {
|
||||
const { session, tabCount } = await captureCurrentSession();
|
||||
const sessions = await getSessions();
|
||||
const session = sessions[name];
|
||||
if (!session) throw new Error(`Session '${name}' not found`);
|
||||
sessions[name] = session;
|
||||
await chrome.storage.local.set({ sessions });
|
||||
return { name, tabs: tabCount };
|
||||
}
|
||||
|
||||
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 });
|
||||
// Public: invoked from index.ts on chrome.tabs.onActivated.
|
||||
async activateLazyTab(tabId: number | string) {
|
||||
const { lazySessionTabs } = await chrome.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("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 [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 (_) {}
|
||||
private async sessionLoad({ name, gentleMode, discardBackgroundTabs = false, lazy = false, eagerTabs = 10, __job }: SessionLoadArgs = {}) {
|
||||
return runLargeOperation("session.load", async () => {
|
||||
const sessions = await getSessions();
|
||||
const session = sessions[name];
|
||||
if (!session) throw new Error(`Session '${name}' not found`);
|
||||
|
||||
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?: LazySessionMap }>("lazySessionTabs");
|
||||
const lazyMap: LazySessionMap = 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));
|
||||
}
|
||||
updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length });
|
||||
await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs));
|
||||
}
|
||||
if (lazy) await chrome.storage.local.set({ lazySessionTabs: lazyMap });
|
||||
if (lazy) await chrome.storage.local.set({ lazySessionTabs: lazyMap });
|
||||
|
||||
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 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);
|
||||
}
|
||||
groups.get(key).tabIds.push(tabId);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
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() {
|
||||
const sessions = await getSessions();
|
||||
return Object.entries(sessions).map(([name, s]) => ({
|
||||
name,
|
||||
tabs: getSessionTabs(s).length,
|
||||
savedAt: s.savedAt || null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function sessionRemove({ name }) {
|
||||
const sessions = await getSessions();
|
||||
if (!(name in sessions)) throw new Error(`Session '${name}' not found`);
|
||||
delete sessions[name];
|
||||
await chrome.storage.local.set({ sessions });
|
||||
return { name };
|
||||
}
|
||||
|
||||
export async function sessionDiff({ nameA, nameB }) {
|
||||
const sessions = await getSessions();
|
||||
const a = new Set(getSessionTabs(sessions[nameA]).map(tab => tab.url));
|
||||
const b = new Set(getSessionTabs(sessions[nameB]).map(tab => tab.url));
|
||||
return {
|
||||
added: [...b].filter(u => !a.has(u)),
|
||||
removed: [...a].filter(u => !b.has(u)),
|
||||
};
|
||||
}
|
||||
|
||||
let autoSaveTimer = null;
|
||||
let autoSaveInFlight = false;
|
||||
let autoSavePending = false;
|
||||
|
||||
export async function sessionAutoSave({ enabled }) {
|
||||
await chrome.storage.local.set({ autoSave: enabled });
|
||||
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.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 { name, tabs: sessionTabs.length, gentle: throttle.gentle, audible: throttle.audible, discarded: Boolean(discardBackgroundTabs), lazy: Boolean(lazy), eagerTabs: eagerLimit };
|
||||
});
|
||||
}
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
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;
|
||||
private async sessionList() {
|
||||
const sessions = await getSessions();
|
||||
return Object.entries(sessions).map(([name, s]) => ({
|
||||
name,
|
||||
tabs: getSessionTabs(s).length,
|
||||
savedAt: s.savedAt || null,
|
||||
}));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
private async sessionRemove({ name }: SessionRemoveArgs) {
|
||||
const sessions = await getSessions();
|
||||
if (!(name in sessions)) throw new Error(`Session '${name}' not found`);
|
||||
delete sessions[name];
|
||||
await chrome.storage.local.set({ sessions });
|
||||
return { name };
|
||||
}
|
||||
|
||||
private async sessionDiff({ nameA, nameB }: SessionDiffArgs) {
|
||||
const sessions = await getSessions();
|
||||
const a = new Set(getSessionTabs(sessions[nameA]).map(tab => tab.url));
|
||||
const b = new Set(getSessionTabs(sessions[nameB]).map(tab => tab.url));
|
||||
return {
|
||||
added: [...b].filter(u => !a.has(u)),
|
||||
removed: [...a].filter(u => !b.has(u)),
|
||||
};
|
||||
}
|
||||
|
||||
private async clientsList() {
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
const alias = await getProfileAlias();
|
||||
return [{
|
||||
name: "Chrome",
|
||||
version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown",
|
||||
platform: navigator.platform,
|
||||
extensionVersion: manifest.version,
|
||||
profile: alias,
|
||||
}];
|
||||
}
|
||||
|
||||
private async clientsRenameProfile({ alias }: ClientsRenameProfileArgs) {
|
||||
await chrome.storage.local.set({ profileAlias: alias });
|
||||
return { alias };
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleAutoSave(delayMs = 1000) {
|
||||
const { autoSave } = await chrome.storage.local.get("autoSave");
|
||||
if (!autoSave) return;
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function clientsList() {
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
const alias = await getProfileAlias();
|
||||
return [{
|
||||
name: "Chrome",
|
||||
version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown",
|
||||
platform: navigator.platform,
|
||||
extensionVersion: manifest.version,
|
||||
profile: alias,
|
||||
}];
|
||||
}
|
||||
|
||||
export async function clientsRenameProfile({ alias }) {
|
||||
await chrome.storage.local.set({ profileAlias: alias });
|
||||
return { alias };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user