import { webExtApi as api } from '../browser-api';
import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, groupTabs, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, updateTabGroup, 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 { Json, LazySessionMap, SessionSaveArgs, SessionRemoveArgs, SessionDiffArgs, SessionImportArgs, SessionLoadArgs, SessionAutoSaveArgs, ClientsRenameProfileArgs, StoredSession } from '../types';
function lazyPlaceholderUrl(url: string) {
const escaped = String(url).replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[ch]));
const html = `
Lazy tabLazy tab
This tab will load when selected.
${escaped}
`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
export class SessionCommands extends CommandGroup {
readonly namespace = "session";
readonly commands: Record = {
"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.export": (a: SessionSaveArgs) => this.sessionExport(a),
"session.import": (a: SessionImportArgs) => this.sessionImport(a),
"session.auto_save": (a: SessionAutoSaveArgs) => this.autoSaveManager.setEnabled(Boolean(a.enabled)),
"clients.list": () => this.clientsList(),
"clients.rename_profile": (a: ClientsRenameProfileArgs) => this.clientsRenameProfile(a),
};
private readonly autoSaveManager = new AutoSaveManager();
private async sessionSave({ name }: SessionSaveArgs) {
const { session, tabCount } = await captureCurrentSession();
const sessions = await getSessions();
sessions[name] = session;
await api.storage.local.set({ sessions });
return { name, tabs: tabCount };
}
// Public: invoked from index.ts on api.tabs.onActivated.
async activateLazyTab(tabId: number | string) {
const { lazySessionTabs } = await api.storage.local.get<{ lazySessionTabs?: LazySessionMap }>("lazySessionTabs");
const entry = lazySessionTabs?.[tabId];
if (!entry?.url) return false;
delete lazySessionTabs[tabId];
await api.storage.local.set({ lazySessionTabs });
await api.tabs.update(Number(tabId), { url: entry.url });
return true;
}
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 api.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 api.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 && api.tabs.discard) {
try { await api.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));
}
if (lazy) await api.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: [] });
}
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 groupTabs({ tabIds });
await updateTabGroup(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 };
});
}
private async sessionList() {
const sessions = await getSessions();
return Object.entries(sessions).map(([name, s]) => ({
name,
tabs: getSessionTabs(s).length,
savedAt: s.savedAt || null,
}));
}
private async sessionRemove({ name }: SessionRemoveArgs) {
const sessions = await getSessions();
if (!(name in sessions)) throw new Error(`Session '${name}' not found`);
delete sessions[name];
await api.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 sessionExport({ name }: SessionSaveArgs) {
const sessions = await getSessions();
if (!name) return { sessions };
const session = sessions[name];
if (!session) throw new Error(`Session '${name}' not found`);
return { name, session };
}
private isSession(value: Json | undefined): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const candidate = value as object as StoredSession;
return Array.isArray(candidate.tabs) || Array.isArray(candidate.urls);
}
private async sessionImport({ name, session, overwrite = false }: SessionImportArgs) {
if (!name) throw new Error("Session name is required");
if (!this.isSession(session)) throw new Error("Session payload must contain tabs or urls");
const sessions = await getSessions();
if (!overwrite && sessions[name]) throw new Error(`Session '${name}' already exists`);
const stored = session as object as StoredSession;
sessions[name] = { ...stored, savedAt: Number(stored.savedAt) || Date.now() };
await api.storage.local.set({ sessions });
return { name, tabs: getSessionTabs(sessions[name]).length };
}
private async clientsList() {
const manifest = api.runtime.getManifest();
const alias = await getProfileAlias();
const browserInfo = api.runtime.getBrowserInfo ? await api.runtime.getBrowserInfo() : null;
const userAgent = navigator.userAgent;
return [{
name: browserInfo?.name || (userAgent.includes("Firefox/") ? "Firefox" : "Chrome"),
version: browserInfo?.version || userAgent.match(/(?:Chrome|Firefox)\/([\d.]+)/)?.[1] || "unknown",
platform: navigator.platform,
extensionVersion: manifest.version,
profile: alias,
}];
}
private async clientsRenameProfile({ alias }: ClientsRenameProfileArgs) {
await api.storage.local.set({ profileAlias: alias });
return { alias };
}
}