ba01be1c5d
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.
85 lines
3.6 KiB
TypeScript
85 lines
3.6 KiB
TypeScript
import { getSessions, runLargeOperation } from '../core';
|
|
import { captureCurrentSession } from './session-snapshot';
|
|
|
|
// Debounce window for autosave. A full-tab snapshot + storage write runs on
|
|
// every tab open/close/move; a longer window coalesces rapid bursts (e.g.
|
|
// opening or closing many tabs at once) into a single snapshot.
|
|
const AUTOSAVE_DEBOUNCE_MS = 3000;
|
|
|
|
export class AutoSaveManager {
|
|
private autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private autoSaveInFlight = false;
|
|
private autoSavePending = false;
|
|
|
|
// Bound handler references — stored so add/removeListener share identity
|
|
// (removeListener must use the SAME reference that was added).
|
|
readonly autoSaveHandler = async (): Promise<void> => {
|
|
await this.scheduleAutoSave();
|
|
};
|
|
readonly autoSaveUpdatedHandler = async (_tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo = {}): Promise<void> => {
|
|
// Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure.
|
|
if (!("url" in changeInfo)) return;
|
|
await this.scheduleAutoSave();
|
|
};
|
|
|
|
async setEnabled(enabled: boolean) {
|
|
await chrome.storage.local.set({ autoSave: enabled });
|
|
chrome.tabs.onCreated.removeListener(this.autoSaveHandler);
|
|
chrome.tabs.onRemoved.removeListener(this.autoSaveHandler);
|
|
chrome.tabs.onMoved.removeListener(this.autoSaveHandler);
|
|
chrome.tabs.onAttached.removeListener(this.autoSaveHandler);
|
|
chrome.tabs.onDetached.removeListener(this.autoSaveHandler);
|
|
chrome.tabs.onUpdated.removeListener(this.autoSaveUpdatedHandler);
|
|
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.removeListener(this.autoSaveHandler);
|
|
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
|
|
this.autoSaveTimer = null;
|
|
this.autoSavePending = false;
|
|
if (enabled) {
|
|
chrome.tabs.onCreated.addListener(this.autoSaveHandler);
|
|
chrome.tabs.onRemoved.addListener(this.autoSaveHandler);
|
|
chrome.tabs.onMoved.addListener(this.autoSaveHandler);
|
|
chrome.tabs.onAttached.addListener(this.autoSaveHandler);
|
|
chrome.tabs.onDetached.addListener(this.autoSaveHandler);
|
|
chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler);
|
|
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(this.autoSaveHandler);
|
|
}
|
|
return { enabled };
|
|
}
|
|
|
|
private async saveAutoSessionIfChanged() {
|
|
const { session, signature, tabCount } = await captureCurrentSession();
|
|
const { autoSaveSignature } = await chrome.storage.local.get("autoSaveSignature");
|
|
if (autoSaveSignature === signature) return { skipped: true, tabs: tabCount };
|
|
|
|
const sessions = await getSessions();
|
|
sessions.__auto__ = session;
|
|
await chrome.storage.local.set({ sessions, autoSaveSignature: signature });
|
|
return { skipped: false, tabs: tabCount };
|
|
}
|
|
|
|
private async runAutoSave() {
|
|
if (this.autoSaveInFlight) {
|
|
this.autoSavePending = true;
|
|
return;
|
|
}
|
|
this.autoSaveInFlight = true;
|
|
try {
|
|
const { autoSave } = await chrome.storage.local.get("autoSave");
|
|
if (autoSave) await runLargeOperation("session.auto_save", () => this.saveAutoSessionIfChanged());
|
|
} finally {
|
|
this.autoSaveInFlight = false;
|
|
if (this.autoSavePending) {
|
|
this.autoSavePending = false;
|
|
this.autoSaveTimer = setTimeout(() => this.runAutoSave(), AUTOSAVE_DEBOUNCE_MS);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async scheduleAutoSave(delayMs = AUTOSAVE_DEBOUNCE_MS) {
|
|
const { autoSave } = await chrome.storage.local.get("autoSave");
|
|
if (!autoSave) return;
|
|
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer);
|
|
this.autoSaveTimer = setTimeout(() => this.runAutoSave(), delayMs);
|
|
}
|
|
}
|