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:
@@ -0,0 +1,84 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,74 @@
|
||||
// @ts-nocheck
|
||||
import { executeScript, getActiveTab, isScriptableUrl } from '../core';
|
||||
export async function storageGet({ key, type = "local", tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
|
||||
import { executeScript, isScriptableUrl, resolveTabUrl } from '../core';
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { Json, StorageGetArgs, StorageSetArgs, CookiesListArgs, CookiesGetArgs, CookiesSetArgs } from '../types';
|
||||
|
||||
export class BrowserDataCommands extends CommandGroup {
|
||||
readonly namespace = "storage";
|
||||
readonly commands: Record<string, CommandEntry> = {
|
||||
"storage.get": (a: StorageGetArgs) => this.storageGet(a),
|
||||
"storage.set": (a: StorageSetArgs) => this.storageSet(a),
|
||||
"cookies.list": (a: CookiesListArgs) => this.cookiesList(a),
|
||||
"cookies.get": (a: CookiesGetArgs) => this.cookiesGet(a),
|
||||
"cookies.set": (a: CookiesSetArgs) => this.cookiesSet(a),
|
||||
};
|
||||
|
||||
private async storageGet({ key, type = "local", tabId }: StorageGetArgs = {}) {
|
||||
const { tab } = await resolveTabUrl(tabId);
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
|
||||
}
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN",
|
||||
func: (k: string | null, t: string) => {
|
||||
const store = t === "session" ? sessionStorage : localStorage;
|
||||
if (k) return store.getItem(k);
|
||||
return Object.fromEntries(Object.keys(store).map(key => [key, store.getItem(key)]));
|
||||
},
|
||||
args: [key || null, type],
|
||||
});
|
||||
return results[0]?.result ?? null;
|
||||
}
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN",
|
||||
func: (k, t) => {
|
||||
const store = t === "session" ? sessionStorage : localStorage;
|
||||
if (k) return store.getItem(k);
|
||||
return Object.fromEntries(Object.keys(store).map(key => [key, store.getItem(key)]));
|
||||
},
|
||||
args: [key || null, type],
|
||||
});
|
||||
return results[0]?.result ?? null;
|
||||
}
|
||||
|
||||
export async function storageSet({ key, value, type = "local", tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
|
||||
private async storageSet({ key, value, type = "local", tabId }: StorageSetArgs = {}) {
|
||||
const { tab } = await resolveTabUrl(tabId);
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
|
||||
}
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN",
|
||||
func: (k: string, v: Json, t: string) => {
|
||||
const store = t === "session" ? sessionStorage : localStorage;
|
||||
store.setItem(k, typeof v === "string" ? v : JSON.stringify(v));
|
||||
return true;
|
||||
},
|
||||
args: [key, value, type],
|
||||
});
|
||||
return results[0]?.result ?? false;
|
||||
}
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN",
|
||||
func: (k, v, t) => {
|
||||
const store = t === "session" ? sessionStorage : localStorage;
|
||||
store.setItem(k, typeof v === "string" ? v : JSON.stringify(v));
|
||||
return true;
|
||||
},
|
||||
args: [key, value, type],
|
||||
});
|
||||
return results[0]?.result ?? false;
|
||||
}
|
||||
|
||||
export async function cookiesList({ url, domain, name } = {}) {
|
||||
const details = {};
|
||||
if (url) details.url = url;
|
||||
if (domain) details.domain = domain;
|
||||
if (name) details.name = name;
|
||||
return await chrome.cookies.getAll(details);
|
||||
}
|
||||
private async cookiesList({ url, domain, name }: CookiesListArgs = {}) {
|
||||
const details: chrome.cookies.GetAllDetails = {};
|
||||
if (url) details.url = url;
|
||||
if (domain) details.domain = domain;
|
||||
if (name) details.name = name;
|
||||
return await chrome.cookies.getAll(details);
|
||||
}
|
||||
|
||||
export async function cookiesGet({ url, name }) {
|
||||
return await chrome.cookies.get({ url, name });
|
||||
}
|
||||
private async cookiesGet({ url, name }: CookiesGetArgs) {
|
||||
return await chrome.cookies.get({ url, name });
|
||||
}
|
||||
|
||||
export async function cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite } = {}) {
|
||||
const details = { url, name, value };
|
||||
if (domain != null) details.domain = domain;
|
||||
if (path != null) details.path = path;
|
||||
if (secure != null) details.secure = secure;
|
||||
if (httpOnly != null) details.httpOnly = httpOnly;
|
||||
if (expirationDate != null) details.expirationDate = expirationDate;
|
||||
if (sameSite != null) details.sameSite = sameSite;
|
||||
return await chrome.cookies.set(details);
|
||||
private async cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite }: CookiesSetArgs = {}) {
|
||||
const details: chrome.cookies.SetDetails = { url, name, value };
|
||||
if (domain != null) details.domain = domain;
|
||||
if (path != null) details.path = path;
|
||||
if (secure != null) details.secure = secure;
|
||||
if (httpOnly != null) details.httpOnly = httpOnly;
|
||||
if (expirationDate != null) details.expirationDate = expirationDate;
|
||||
if (sameSite != null) details.sameSite = sameSite;
|
||||
return await chrome.cookies.set(details);
|
||||
}
|
||||
}
|
||||
|
||||
// This function is serialized and injected into the page by chrome.scripting
|
||||
|
||||
+132
-100
@@ -1,8 +1,10 @@
|
||||
// @ts-nocheck
|
||||
import { executeScript, getActiveTab, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl } from '../core';
|
||||
import { assertScriptableUrl, executeScript, fetchTabHtml, isBrowserErrorUrl, isErrorPageScriptError, resolveTabUrl } from '../core';
|
||||
import { contentDispatch } from './injected';
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { DomArgs, DomEvalArgs, DomWaitForArgs, DomPollArgs, Serializable } from '../types';
|
||||
|
||||
function fallbackForErrorPageDomOp(funcName, tab) {
|
||||
function fallbackForErrorPageDomOp(funcName: string, tab: chrome.tabs.Tab): Serializable {
|
||||
switch (funcName) {
|
||||
case "domExists":
|
||||
return false;
|
||||
@@ -20,7 +22,7 @@ function fallbackForErrorPageDomOp(funcName, tab) {
|
||||
title: tab.title || "",
|
||||
url: tab.url || tab.pendingUrl || "",
|
||||
readyState: "error",
|
||||
lang: null,
|
||||
lang: null as string | null,
|
||||
meta: {},
|
||||
};
|
||||
default:
|
||||
@@ -28,113 +30,143 @@ function fallbackForErrorPageDomOp(funcName, tab) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function domOp(funcName, args = {}) {
|
||||
const tab = args?.tabId ? await chrome.tabs.get(args.tabId) : await getActiveTab();
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
const fallback = fallbackForErrorPageDomOp(funcName, tab);
|
||||
if (fallback !== undefined) return fallback;
|
||||
}
|
||||
if (!isScriptableUrl(tabUrl)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: contentDispatch,
|
||||
args: [funcName, args],
|
||||
});
|
||||
return results[0]?.result;
|
||||
} catch (e) {
|
||||
if (isErrorPageScriptError(e)) {
|
||||
export class DomCommands extends CommandGroup {
|
||||
readonly namespace = "dom";
|
||||
|
||||
readonly commands: Record<string, CommandEntry> = {
|
||||
// ── DOM ──────────────────────────────────────────────────────────────
|
||||
"dom.query": (a: DomArgs) => this.domOp("domQuery", a),
|
||||
"dom.click": (a: DomArgs) => this.domOp("domClick", a),
|
||||
"dom.type": (a: DomArgs) => this.domOp("domType", a),
|
||||
"dom.attr": (a: DomArgs) => this.domOp("domAttr", a),
|
||||
"dom.text": (a: DomArgs) => this.domOp("domText", a),
|
||||
"dom.exists": (a: DomArgs) => this.domOp("domExists", a),
|
||||
"dom.scroll": (a: DomArgs) => this.domOp("domScroll", a),
|
||||
"dom.select": (a: DomArgs) => this.domOp("domSelect", a),
|
||||
"dom.key": (a: DomArgs) => this.domOp("domKey", a),
|
||||
"dom.hover": (a: DomArgs) => this.domOp("domHover", a),
|
||||
"dom.check": (a: DomArgs) => this.domOp("domCheck", { ...a, checked: true }),
|
||||
"dom.uncheck": (a: DomArgs) => this.domOp("domCheck", { ...a, checked: false }),
|
||||
"dom.clear": (a: DomArgs) => this.domOp("domClear", a),
|
||||
"dom.focus": (a: DomArgs) => this.domOp("domFocus", a),
|
||||
"dom.submit": (a: DomArgs) => this.domOp("domSubmit", a),
|
||||
"dom.eval": (a: DomEvalArgs) => this.domEval(a),
|
||||
"dom.wait_for": (a: DomWaitForArgs) => this.domWaitFor(a),
|
||||
"dom.poll": (a: DomPollArgs) => this.domPoll(a),
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────
|
||||
"page.info": () => this.domOp("pageInfo", {}),
|
||||
|
||||
// ── Extract ──────────────────────────────────────────────────────────
|
||||
"extract.links": (a: DomArgs) => this.domOp("extractLinks", a),
|
||||
"extract.images": (a: DomArgs) => this.domOp("extractImages", a),
|
||||
"extract.text": (a: DomArgs) => this.domOp("extractText", a),
|
||||
"extract.json": (a: DomArgs) => this.domOp("extractJson", a),
|
||||
"extract.markdown": (a: DomArgs) => this.domOp("extractMarkdown", a),
|
||||
"extract.html": () => fetchTabHtml(undefined),
|
||||
};
|
||||
|
||||
private async domOp(funcName: string, args: DomArgs = {}) {
|
||||
const { tab, url: tabUrl } = await resolveTabUrl(args?.tabId);
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
const fallback = fallbackForErrorPageDomOp(funcName, tab);
|
||||
if (fallback !== undefined) return fallback;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function domEval({ code, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (!isScriptableUrl(tabUrl) || isBrowserErrorUrl(tabUrl)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN",
|
||||
func: (c) => (0, eval)(c),
|
||||
args: [code],
|
||||
});
|
||||
return results[0]?.result ?? null;
|
||||
}
|
||||
|
||||
export async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
if (hidden) return { selector, found: false };
|
||||
throw new Error(`Cannot wait for DOM on browser error page ${tabUrl}`);
|
||||
}
|
||||
if (!isScriptableUrl(tabUrl)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
assertScriptableUrl(tabUrl, "run DOM commands on");
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (sel, vis, hid) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (hid) return !el || el.offsetParent === null;
|
||||
if (!el) return false;
|
||||
if (vis) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
args: [selector, visible, hidden],
|
||||
func: contentDispatch,
|
||||
args: [funcName, args],
|
||||
});
|
||||
if (results[0]?.result) return { selector, found: !hidden };
|
||||
return results[0]?.result;
|
||||
} catch (e) {
|
||||
if (hidden && isErrorPageScriptError(e)) return { selector, found: false };
|
||||
if (!isErrorPageScriptError(e)) throw e;
|
||||
if (isErrorPageScriptError(e)) {
|
||||
const fallback = fallbackForErrorPageDomOp(funcName, tab);
|
||||
if (fallback !== undefined) return fallback;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`);
|
||||
}
|
||||
|
||||
export async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
throw new Error(`Cannot poll DOM on browser error page ${tabUrl}`);
|
||||
}
|
||||
if (!isScriptableUrl(tabUrl)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
const deadline = Date.now() + timeout;
|
||||
const regex = new RegExp(pattern);
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (sel, a) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return null;
|
||||
if (a) return el.getAttribute(a) ?? el[a] ?? null;
|
||||
return el.value !== undefined ? el.value : el.textContent.trim();
|
||||
},
|
||||
args: [selector, attr || null],
|
||||
});
|
||||
const value = results[0]?.result;
|
||||
if (value != null && regex.test(String(value))) return { selector, value, pattern };
|
||||
} catch (e) {
|
||||
if (!isErrorPageScriptError(e)) throw e;
|
||||
private async domEval({ code, tabId }: DomEvalArgs = {}) {
|
||||
const { tab, url: tabUrl } = await resolveTabUrl(tabId);
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
assertScriptableUrl(tabUrl, "run DOM commands on");
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN",
|
||||
func: (c: string) => (0, eval)(c),
|
||||
args: [code],
|
||||
});
|
||||
return results[0]?.result ?? null;
|
||||
}
|
||||
|
||||
private async domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId }: DomWaitForArgs = {}) {
|
||||
const { tab, url: tabUrl } = await resolveTabUrl(tabId);
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
if (hidden) return { selector, found: false };
|
||||
throw new Error(`Cannot wait for DOM on browser error page ${tabUrl}`);
|
||||
}
|
||||
assertScriptableUrl(tabUrl, "run DOM commands on");
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (sel: string, vis: boolean, hid: boolean) => {
|
||||
const el = document.querySelector(sel) as HTMLElement | null;
|
||||
if (hid) return !el || el.offsetParent === null;
|
||||
if (!el) return false;
|
||||
if (vis) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
args: [selector, visible, hidden],
|
||||
});
|
||||
if (results[0]?.result) return { selector, found: !hidden };
|
||||
} catch (e) {
|
||||
if (hidden && isErrorPageScriptError(e)) return { selector, found: false };
|
||||
if (!isErrorPageScriptError(e)) throw e;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`);
|
||||
}
|
||||
|
||||
private async domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId }: DomPollArgs = {}) {
|
||||
const { tab, url: tabUrl } = await resolveTabUrl(tabId);
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
throw new Error(`Cannot poll DOM on browser error page ${tabUrl}`);
|
||||
}
|
||||
assertScriptableUrl(tabUrl, "run DOM commands on");
|
||||
const deadline = Date.now() + timeout;
|
||||
const regex = new RegExp(pattern);
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (sel: string, a: string | null) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return null;
|
||||
// Page elements are accessed dynamically (arbitrary attr / .value).
|
||||
const dyn = el as Element & { value?: string } & Record<string, string | null | undefined>;
|
||||
if (a) return el.getAttribute(a) ?? dyn[a] ?? null;
|
||||
return dyn.value !== undefined ? dyn.value : el.textContent.trim();
|
||||
},
|
||||
args: [selector, attr || null],
|
||||
});
|
||||
const value = results[0]?.result;
|
||||
if (value != null && regex.test(String(value))) return { selector, value, pattern };
|
||||
} catch (e) {
|
||||
if (!isErrorPageScriptError(e)) throw e;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
}
|
||||
throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`);
|
||||
}
|
||||
throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
|
||||
export class ExtensionCommands extends CommandGroup {
|
||||
readonly namespace = "extension";
|
||||
readonly commands: Record<string, CommandEntry> = {
|
||||
"extension.reload": () => {
|
||||
setTimeout(() => chrome.runtime.reload(), 200);
|
||||
return { reloading: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,105 +1,114 @@
|
||||
// @ts-nocheck
|
||||
import { buildTabBlocks, getLargeOperationThrottle, resolveGroupId, runLargeOperation, tabInfo, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
|
||||
export async function groupList() {
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
const all = await chrome.tabs.query({});
|
||||
return groups.map(g => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
color: g.color,
|
||||
collapsed: g.collapsed,
|
||||
windowId: g.windowId,
|
||||
tabCount: all.filter(t => t.groupId === g.id).length,
|
||||
}));
|
||||
}
|
||||
import { asTabIds, buildTabBlocks, getLargeOperationThrottle, processInBatches, resolveGroupId, runLargeOperation, tabInfo } from '../core';
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { GroupTabsArgs, GroupQueryArgs, GroupCloseArgs, GroupOpenArgs, GroupAddTabArgs, GroupMoveArgs } from '../types';
|
||||
|
||||
export async function groupTabs({ groupId }) {
|
||||
const all = await chrome.tabs.query({});
|
||||
return all.filter(t => t.groupId === groupId).map(tabInfo);
|
||||
}
|
||||
export class GroupsCommands extends CommandGroup {
|
||||
readonly namespace = "group";
|
||||
readonly commands: Record<string, CommandEntry> = {
|
||||
"group.list": () => this.groupList(),
|
||||
"group.tabs": (a: GroupTabsArgs) => this.groupTabs(a),
|
||||
"group.count": () => this.groupCount(),
|
||||
"group.query": (a: GroupQueryArgs) => this.groupQuery(a),
|
||||
"group.close": { background: true, run: (a: GroupCloseArgs) => this.groupClose(a) },
|
||||
"group.open": (a: GroupOpenArgs) => this.groupOpen(a),
|
||||
"group.add_tab": (a: GroupAddTabArgs) => this.groupAddTab(a),
|
||||
"group.move": (a: GroupMoveArgs) => this.groupMove(a),
|
||||
};
|
||||
|
||||
export async function groupCount() {
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
return groups.length;
|
||||
}
|
||||
private async groupList() {
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
const all = await chrome.tabs.query({});
|
||||
return groups.map(g => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
color: g.color,
|
||||
collapsed: g.collapsed,
|
||||
windowId: g.windowId,
|
||||
tabCount: all.filter(t => t.groupId === g.id).length,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function groupQuery({ search }) {
|
||||
const q = search.toLowerCase();
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
return groups.filter(g => g.title && g.title.toLowerCase().includes(q));
|
||||
}
|
||||
private async groupTabs({ groupId }: GroupTabsArgs) {
|
||||
const all = await chrome.tabs.query({});
|
||||
return all.filter(t => t.groupId === groupId).map(tabInfo);
|
||||
}
|
||||
|
||||
export async function groupClose({ groupId, gentleMode, __job } = {}) {
|
||||
return runLargeOperation("group.close", async () => {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const groupTabs = tabs.filter(t => t.groupId === groupId);
|
||||
const tabIds = groupTabs.map(t => t.id);
|
||||
const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode);
|
||||
updateJobProgress(__job, { phase: "ungrouping tabs", current: 0, total: tabIds.length });
|
||||
for (let i = 0; i < tabIds.length; i += throttle.batchSize) {
|
||||
throwIfJobCancelled(__job);
|
||||
await chrome.tabs.ungroup(tabIds.slice(i, i + throttle.batchSize));
|
||||
updateJobProgress(__job, { phase: "ungrouping tabs", current: Math.min(i + throttle.batchSize, tabIds.length), total: tabIds.length });
|
||||
await yieldForLargeOperation(i + throttle.batchSize, throttle.batchSize, throttle.pauseMs);
|
||||
private async groupCount() {
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
return groups.length;
|
||||
}
|
||||
|
||||
private async groupQuery({ search }: GroupQueryArgs) {
|
||||
const q = search.toLowerCase();
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
return groups.filter(g => g.title && g.title.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
private async groupClose({ groupId, gentleMode, __job }: GroupCloseArgs = {}) {
|
||||
return runLargeOperation("group.close", async () => {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const groupTabs = tabs.filter(t => t.groupId === groupId);
|
||||
const tabIds = groupTabs.map(t => t.id);
|
||||
const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode);
|
||||
await processInBatches(tabIds, throttle, batch => chrome.tabs.ungroup(asTabIds(batch)), { job: __job, phase: "ungrouping tabs" });
|
||||
return { groupId, gentle: throttle.gentle, audible: throttle.audible };
|
||||
});
|
||||
}
|
||||
|
||||
private async groupOpen({ name }: GroupOpenArgs) {
|
||||
const tab = await chrome.tabs.create({ active: true });
|
||||
const groupId = await chrome.tabs.group({ tabIds: asTabIds([tab.id]) });
|
||||
await chrome.tabGroups.update(groupId, { title: name });
|
||||
return { id: groupId, name };
|
||||
}
|
||||
|
||||
private async groupAddTab({ group, url }: GroupAddTabArgs) {
|
||||
const groupId = await resolveGroupId(group);
|
||||
const existingTabs = await chrome.tabs.query({ groupId });
|
||||
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true });
|
||||
await chrome.tabs.group({ tabIds: asTabIds([tab.id]), groupId });
|
||||
// If a URL was provided, close any blank placeholder tabs left from group creation
|
||||
if (url) {
|
||||
const placeholders = existingTabs.filter(t =>
|
||||
t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/"
|
||||
);
|
||||
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
|
||||
}
|
||||
return { groupId, gentle: throttle.gentle, audible: throttle.audible };
|
||||
});
|
||||
}
|
||||
|
||||
export async function groupOpen({ name }) {
|
||||
const tab = await chrome.tabs.create({ active: true });
|
||||
const groupId = await chrome.tabs.group({ tabIds: [tab.id] });
|
||||
await chrome.tabGroups.update(groupId, { title: name });
|
||||
return { id: groupId, name };
|
||||
}
|
||||
|
||||
export async function groupAddTab({ group, url }) {
|
||||
const groupId = await resolveGroupId(group);
|
||||
const existingTabs = await chrome.tabs.query({ groupId });
|
||||
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true });
|
||||
await chrome.tabs.group({ tabIds: [tab.id], groupId });
|
||||
// If a URL was provided, close any blank placeholder tabs left from group creation
|
||||
if (url) {
|
||||
const placeholders = existingTabs.filter(t =>
|
||||
t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/"
|
||||
);
|
||||
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
|
||||
}
|
||||
return { tabId: tab.id, groupId };
|
||||
}
|
||||
|
||||
export async function groupMove({ group, forward, backward }) {
|
||||
const groupId = await resolveGroupId(group);
|
||||
const groupInfo = await chrome.tabGroups.get(groupId);
|
||||
const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId });
|
||||
allTabs.sort((a, b) => a.index - b.index);
|
||||
|
||||
const blocks = buildTabBlocks(allTabs);
|
||||
const currentIdx = blocks.findIndex(block => block.groupId === groupId);
|
||||
if (currentIdx === -1) throw new Error(`No tabs found in group '${group}'`);
|
||||
|
||||
const currentBlock = blocks[currentIdx];
|
||||
const currentLength = currentBlock.tabIds.length;
|
||||
|
||||
if (forward) {
|
||||
const nextBlock = blocks[currentIdx + 1];
|
||||
if (!nextBlock) return { groupId, moved: false };
|
||||
const targetIndex =
|
||||
nextBlock.groupId === null
|
||||
? currentBlock.startIndex + 1
|
||||
: nextBlock.endIndex - currentLength + 1;
|
||||
await chrome.tabGroups.move(groupId, { index: targetIndex });
|
||||
} else if (backward) {
|
||||
const previousBlock = blocks[currentIdx - 1];
|
||||
if (!previousBlock) return { groupId, moved: false };
|
||||
const targetIndex =
|
||||
previousBlock.groupId === null
|
||||
? currentBlock.startIndex - 1
|
||||
: previousBlock.startIndex;
|
||||
await chrome.tabGroups.move(groupId, { index: targetIndex });
|
||||
return { tabId: tab.id, groupId };
|
||||
}
|
||||
|
||||
return { groupId, moved: true };
|
||||
}
|
||||
private async groupMove({ group, forward, backward }: GroupMoveArgs) {
|
||||
const groupId = await resolveGroupId(group);
|
||||
const groupInfo = await chrome.tabGroups.get(groupId);
|
||||
const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId });
|
||||
allTabs.sort((a, b) => a.index - b.index);
|
||||
|
||||
// ── Windows ───────────────────────────────────────────────────────────────────
|
||||
const blocks = buildTabBlocks(allTabs);
|
||||
const currentIdx = blocks.findIndex(block => block.groupId === groupId);
|
||||
if (currentIdx === -1) throw new Error(`No tabs found in group '${group}'`);
|
||||
|
||||
const currentBlock = blocks[currentIdx];
|
||||
const currentLength = currentBlock.tabIds.length;
|
||||
|
||||
if (forward) {
|
||||
const nextBlock = blocks[currentIdx + 1];
|
||||
if (!nextBlock) return { groupId, moved: false };
|
||||
const targetIndex =
|
||||
nextBlock.groupId === null
|
||||
? currentBlock.startIndex + 1
|
||||
: nextBlock.endIndex - currentLength + 1;
|
||||
await chrome.tabGroups.move(groupId, { index: targetIndex });
|
||||
} else if (backward) {
|
||||
const previousBlock = blocks[currentIdx - 1];
|
||||
if (!previousBlock) return { groupId, moved: false };
|
||||
const targetIndex =
|
||||
previousBlock.groupId === null
|
||||
? currentBlock.startIndex - 1
|
||||
: previousBlock.startIndex;
|
||||
await chrome.tabGroups.move(groupId, { index: targetIndex });
|
||||
}
|
||||
|
||||
return { groupId, moved: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
// @ts-nocheck
|
||||
export function contentDispatch(funcName, args) {
|
||||
function domQuery({ selector }) {
|
||||
import type { ContentArgs } from '../types';
|
||||
|
||||
export function contentDispatch(funcName: string, args: ContentArgs) {
|
||||
function domQuery({ selector }: ContentArgs) {
|
||||
return Array.from(document.querySelectorAll(selector)).map(el => ({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
text: el.textContent.trim().slice(0, 200),
|
||||
attrs: Object.fromEntries(Array.from(el.attributes).map(a => [a.name, a.value])),
|
||||
}));
|
||||
}
|
||||
function domClick({ selector }) {
|
||||
const el = document.querySelector(selector);
|
||||
function domClick({ selector }: ContentArgs) {
|
||||
const el = document.querySelector(selector) as HTMLElement | null;
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.click();
|
||||
return true;
|
||||
}
|
||||
function domType({ selector, text }) {
|
||||
const el = document.querySelector(selector);
|
||||
function domType({ selector, text }: ContentArgs) {
|
||||
const el = document.querySelector(selector) as HTMLInputElement | null;
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.focus();
|
||||
el.value = text;
|
||||
@@ -22,20 +23,20 @@ export function contentDispatch(funcName, args) {
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
function domAttr({ selector, attr }) {
|
||||
function domAttr({ selector, attr }: ContentArgs) {
|
||||
return Array.from(document.querySelectorAll(selector))
|
||||
.map(el => el.getAttribute(attr))
|
||||
.filter(v => v !== null);
|
||||
}
|
||||
function domText({ selector }) {
|
||||
function domText({ selector }: ContentArgs) {
|
||||
return Array.from(document.querySelectorAll(selector))
|
||||
.map(el => el.textContent.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
function domExists({ selector }) {
|
||||
function domExists({ selector }: ContentArgs) {
|
||||
return document.querySelector(selector) !== null;
|
||||
}
|
||||
function domKey({ selector, key }) {
|
||||
function domKey({ selector, key }: ContentArgs) {
|
||||
const el = selector ? document.querySelector(selector) : document.activeElement;
|
||||
if (selector && !el) throw new Error(`No element: ${selector}`);
|
||||
const target = el || document.body;
|
||||
@@ -44,44 +45,44 @@ export function contentDispatch(funcName, args) {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
function domHover({ selector }) {
|
||||
function domHover({ selector }: ContentArgs) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
function domCheck({ selector, checked }) {
|
||||
const el = document.querySelector(selector);
|
||||
function domCheck({ selector, checked }: ContentArgs) {
|
||||
const el = document.querySelector(selector) as HTMLInputElement | null;
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.checked = checked;
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
function domClear({ selector }) {
|
||||
const el = document.querySelector(selector);
|
||||
function domClear({ selector }: ContentArgs) {
|
||||
const el = document.querySelector(selector) as HTMLInputElement | null;
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.value = "";
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
function domFocus({ selector }) {
|
||||
const el = document.querySelector(selector);
|
||||
function domFocus({ selector }: ContentArgs) {
|
||||
const el = document.querySelector(selector) as HTMLElement | null;
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.focus();
|
||||
return true;
|
||||
}
|
||||
function domSubmit({ selector }) {
|
||||
function domSubmit({ selector }: ContentArgs) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
const form = el.tagName === "FORM" ? el : el.closest("form");
|
||||
const form = (el.tagName === "FORM" ? el : el.closest("form")) as HTMLFormElement | null;
|
||||
if (!form) throw new Error(`No form found for: ${selector}`);
|
||||
form.submit();
|
||||
return true;
|
||||
}
|
||||
function pageInfo() {
|
||||
const metas = {};
|
||||
const metas: Record<string, string> = {};
|
||||
document.querySelectorAll("meta[name], meta[property]").forEach(m => {
|
||||
const k = m.getAttribute("name") || m.getAttribute("property");
|
||||
if (k) metas[k] = m.getAttribute("content") || "";
|
||||
@@ -94,7 +95,7 @@ export function contentDispatch(funcName, args) {
|
||||
meta: metas,
|
||||
};
|
||||
}
|
||||
function domScroll({ selector, x, y }) {
|
||||
function domScroll({ selector, x, y }: ContentArgs) {
|
||||
if (selector) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
@@ -104,8 +105,8 @@ export function contentDispatch(funcName, args) {
|
||||
window.scrollTo({ top: y || 0, left: x || 0, behavior: "smooth" });
|
||||
return true;
|
||||
}
|
||||
function domSelect({ selector, value }) {
|
||||
const el = document.querySelector(selector);
|
||||
function domSelect({ selector, value }: ContentArgs) {
|
||||
const el = document.querySelector(selector) as HTMLSelectElement | null;
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
@@ -113,9 +114,9 @@ export function contentDispatch(funcName, args) {
|
||||
return true;
|
||||
}
|
||||
function extractLinks() {
|
||||
const seen = new Set();
|
||||
return Array.from(document.querySelectorAll("a[href]")).reduce((links, a) => {
|
||||
const href = a.href;
|
||||
const seen = new Set<string>();
|
||||
return Array.from(document.querySelectorAll("a[href]")).reduce<Array<{ text: string; href: string }>>((links, a) => {
|
||||
const href = (a as HTMLAnchorElement).href;
|
||||
if (!href || seen.has(href)) return links;
|
||||
seen.add(href);
|
||||
links.push({
|
||||
@@ -126,8 +127,8 @@ export function contentDispatch(funcName, args) {
|
||||
}, []);
|
||||
}
|
||||
function extractImages() {
|
||||
const seen = new Set();
|
||||
return Array.from(document.querySelectorAll("img")).reduce((images, img) => {
|
||||
const seen = new Set<string>();
|
||||
return Array.from(document.querySelectorAll("img")).reduce<Array<{ alt: string; src: string }>>((images, img) => {
|
||||
const src =
|
||||
img.src ||
|
||||
img.getAttribute("data-src") ||
|
||||
@@ -146,12 +147,12 @@ export function contentDispatch(funcName, args) {
|
||||
function extractText() {
|
||||
return document.body.innerText;
|
||||
}
|
||||
function extractJson({ selector }) {
|
||||
function extractJson({ selector }: ContentArgs) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
return JSON.parse(el.textContent);
|
||||
}
|
||||
function extractMarkdown({ selector }) {
|
||||
function extractMarkdown({ selector }: ContentArgs) {
|
||||
const BLOCKS = new Set([
|
||||
"article", "aside", "blockquote", "body", "div", "dl", "fieldset", "figcaption",
|
||||
"figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr",
|
||||
@@ -185,11 +186,11 @@ export function contentDispatch(funcName, args) {
|
||||
"[data-testid*='action-button']",
|
||||
].join(", ");
|
||||
|
||||
function normalizeText(value) {
|
||||
function normalizeText(value: string) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeInline(value) {
|
||||
function normalizeInline(value: string) {
|
||||
return value
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n[ \t]+/g, "\n")
|
||||
@@ -198,46 +199,47 @@ export function contentDispatch(funcName, args) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function collapseBlankLines(value) {
|
||||
function collapseBlankLines(value: string) {
|
||||
return value
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeMarkdown(text) {
|
||||
function escapeMarkdown(text: string) {
|
||||
return text.replace(/([\\`[\]])/g, "\\$1");
|
||||
}
|
||||
|
||||
function escapeTableCell(text) {
|
||||
function escapeTableCell(text: string) {
|
||||
return text.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
|
||||
}
|
||||
|
||||
function absoluteUrl(attr, fallback) {
|
||||
function absoluteUrl(attr: string | null | undefined, fallback?: string) {
|
||||
return attr || fallback || "";
|
||||
}
|
||||
|
||||
function isNoiseElement(node) {
|
||||
function isNoiseElement(node: Node | null): boolean {
|
||||
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
|
||||
const tag = node.tagName.toLowerCase();
|
||||
const el = node as Element;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (["script", "style", "noscript", "template", "svg", "canvas", "iframe", "dialog"].includes(tag)) return true;
|
||||
if (["button", "input", "textarea", "select", "option", "form"].includes(tag)) return true;
|
||||
if (node.hasAttribute("hidden")) return true;
|
||||
if ((node.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true;
|
||||
if (node.matches(".sr-only, [class*='sr-only']")) return true;
|
||||
if (node.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true;
|
||||
if (node.matches("[data-testid*='action-button']")) return true;
|
||||
if (el.hasAttribute("hidden")) return true;
|
||||
if ((el.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true;
|
||||
if (el.matches(".sr-only, [class*='sr-only']")) return true;
|
||||
if (el.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true;
|
||||
if (el.matches("[data-testid*='action-button']")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripNoise(root) {
|
||||
const clone = root.cloneNode(true);
|
||||
function stripNoise(root: Element): Element {
|
||||
const clone = root.cloneNode(true) as Element;
|
||||
clone.querySelectorAll(NOISE_SELECTOR).forEach(node => node.remove());
|
||||
return clone;
|
||||
}
|
||||
|
||||
function candidateScore(node) {
|
||||
const text = normalizeText(node.innerText || "");
|
||||
function candidateScore(node: Element) {
|
||||
const text = normalizeText((node as HTMLElement).innerText || "");
|
||||
if (!text) return -Infinity;
|
||||
|
||||
const headings = node.querySelectorAll("h1, h2, h3, h4, h5, h6").length;
|
||||
@@ -276,73 +278,76 @@ export function contentDispatch(funcName, args) {
|
||||
const candidates = Array.from(document.querySelectorAll(
|
||||
"main, article, [role='main'], section, .markdown, .prose, [data-message-author-role]"
|
||||
))
|
||||
.filter(node => normalizeText(node.innerText || "").length > 0);
|
||||
.filter(node => normalizeText((node as HTMLElement).innerText || "").length > 0);
|
||||
if (!candidates.length) return document.body;
|
||||
candidates.sort((a, b) => candidateScore(b) - candidateScore(a));
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function inlineText(node) {
|
||||
function inlineText(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return escapeMarkdown(node.textContent || "");
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
if (isNoiseElement(node)) return "";
|
||||
|
||||
const tag = node.tagName.toLowerCase();
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "br") return "\n";
|
||||
if (tag === "img") {
|
||||
const src = absoluteUrl(node.getAttribute("src"), node.src);
|
||||
const img = el as HTMLImageElement;
|
||||
const src = absoluteUrl(img.getAttribute("src"), img.src);
|
||||
if (!src) return "";
|
||||
const alt = normalizeText(node.getAttribute("alt") || "");
|
||||
const alt = normalizeText(img.getAttribute("alt") || "");
|
||||
return alt ? `` : ``;
|
||||
}
|
||||
if (tag === "a") {
|
||||
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
|
||||
const href = absoluteUrl(node.getAttribute("href"), node.href);
|
||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||
const href = absoluteUrl(el.getAttribute("href"), (el as HTMLAnchorElement).href);
|
||||
if (!href) return text;
|
||||
return `[${text || href}](${href})`;
|
||||
}
|
||||
if (tag === "code") {
|
||||
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
|
||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||
return text ? `\`${text.replace(/`/g, "\\`")}\`` : "";
|
||||
}
|
||||
if (tag === "strong" || tag === "b") {
|
||||
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
|
||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||
return text ? `**${text}**` : "";
|
||||
}
|
||||
if (tag === "em" || tag === "i") {
|
||||
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
|
||||
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
|
||||
return text ? `*${text}*` : "";
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
for (const child of node.childNodes) {
|
||||
const chunks: string[] = [];
|
||||
for (const child of el.childNodes) {
|
||||
const rendered = inlineText(child);
|
||||
if (!rendered) continue;
|
||||
chunks.push(rendered);
|
||||
if (child.nodeType === Node.ELEMENT_NODE && BLOCKS.has(child.tagName.toLowerCase())) {
|
||||
if (child.nodeType === Node.ELEMENT_NODE && BLOCKS.has((child as Element).tagName.toLowerCase())) {
|
||||
chunks.push("\n");
|
||||
}
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
function textBlock(node) {
|
||||
function textBlock(node: Node): string {
|
||||
return collapseBlankLines(normalizeInline(Array.from(node.childNodes).map(inlineText).join("")));
|
||||
}
|
||||
|
||||
function preserveNodeText(node) {
|
||||
function preserveNodeText(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent || "";
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
|
||||
const tag = node.tagName.toLowerCase();
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "br") return "\n";
|
||||
|
||||
const parts = [];
|
||||
for (const child of node.childNodes) {
|
||||
const parts: string[] = [];
|
||||
for (const child of el.childNodes) {
|
||||
const rendered = preserveNodeText(child);
|
||||
if (!rendered) continue;
|
||||
parts.push(rendered);
|
||||
@@ -354,7 +359,7 @@ export function contentDispatch(funcName, args) {
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function repairFlattenedDiagram(text) {
|
||||
function repairFlattenedDiagram(text: string): string {
|
||||
if (text.includes("\n")) return text;
|
||||
const markerCount = (text.match(/[│▼├└]/g) || []).length;
|
||||
if (markerCount < 2) return text;
|
||||
@@ -372,8 +377,8 @@ export function contentDispatch(funcName, args) {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function convertDashListsToBranches(lines) {
|
||||
const converted = [];
|
||||
function convertDashListsToBranches(lines: string[]): string[] {
|
||||
const converted: string[] = [];
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const match = lines[index].match(/^(\s*)-\s+(.*)$/);
|
||||
@@ -400,7 +405,7 @@ export function contentDispatch(funcName, args) {
|
||||
return converted;
|
||||
}
|
||||
|
||||
function normalizeCodeBlock(text) {
|
||||
function normalizeCodeBlock(text: string): string {
|
||||
let lines = text.replace(/\r\n?/g, "\n").split("\n").map(line => line.replace(/\s+$/, ""));
|
||||
while (lines.length && !lines[0].trim()) lines.shift();
|
||||
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
||||
@@ -418,7 +423,7 @@ export function contentDispatch(funcName, args) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function tableToMarkdown(table) {
|
||||
function tableToMarkdown(table: Element) {
|
||||
const rows = Array.from(table.querySelectorAll("tr"))
|
||||
.map(row => Array.from(row.children)
|
||||
.filter(cell => cell.tagName === "TD" || cell.tagName === "TH")
|
||||
@@ -461,19 +466,20 @@ export function contentDispatch(funcName, args) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function listToMarkdown(list, depth = 0) {
|
||||
function listToMarkdown(list: Element, depth = 0): string {
|
||||
const ordered = list.tagName.toLowerCase() === "ol";
|
||||
const items = [];
|
||||
const items: string[] = [];
|
||||
const children = Array.from(list.children).filter(child => child.tagName === "LI");
|
||||
children.forEach((item, index) => {
|
||||
const marker = ordered ? `${index + 1}. ` : "- ";
|
||||
const indent = " ".repeat(depth);
|
||||
const nested = [];
|
||||
const content = [];
|
||||
const nested: string[] = [];
|
||||
const content: string[] = [];
|
||||
|
||||
for (const child of item.childNodes) {
|
||||
if (child.nodeType === Node.ELEMENT_NODE && (child.tagName === "UL" || child.tagName === "OL")) {
|
||||
nested.push(listToMarkdown(child, depth + 1));
|
||||
const childEl = child as Element;
|
||||
if (child.nodeType === Node.ELEMENT_NODE && (childEl.tagName === "UL" || childEl.tagName === "OL")) {
|
||||
nested.push(listToMarkdown(childEl, depth + 1));
|
||||
} else {
|
||||
content.push(inlineText(child));
|
||||
}
|
||||
@@ -491,18 +497,19 @@ export function contentDispatch(funcName, args) {
|
||||
return items.join("\n");
|
||||
}
|
||||
|
||||
function blockToMarkdown(node) {
|
||||
function blockToMarkdown(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return normalizeText(node.textContent || "");
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
if (isNoiseElement(node)) return "";
|
||||
|
||||
const tag = node.tagName.toLowerCase();
|
||||
if (tag === "table") return tableToMarkdown(node);
|
||||
if (tag === "ul" || tag === "ol") return listToMarkdown(node);
|
||||
if (node.matches(".cm-editor[data-is-code-block-view='true']")) {
|
||||
const lines = Array.from(node.querySelectorAll(".cm-line")).map(line => {
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "table") return tableToMarkdown(el);
|
||||
if (tag === "ul" || tag === "ol") return listToMarkdown(el);
|
||||
if (el.matches(".cm-editor[data-is-code-block-view='true']")) {
|
||||
const lines = Array.from(el.querySelectorAll(".cm-line")).map(line => {
|
||||
const text = preserveNodeText(line);
|
||||
return text === "\n" ? "" : text.replace(/\n$/, "");
|
||||
});
|
||||
@@ -510,11 +517,11 @@ export function contentDispatch(funcName, args) {
|
||||
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
|
||||
}
|
||||
if (tag === "pre") {
|
||||
const code = normalizeCodeBlock(preserveNodeText(node));
|
||||
const code = normalizeCodeBlock(preserveNodeText(el));
|
||||
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
|
||||
}
|
||||
if (tag === "blockquote") {
|
||||
const content = collapseBlankLines(Array.from(node.childNodes).map(blockToMarkdown).join("\n\n"));
|
||||
const content = collapseBlankLines(Array.from(el.childNodes).map(blockToMarkdown).join("\n\n"));
|
||||
return content
|
||||
.split("\n")
|
||||
.map(line => line ? `> ${line}` : ">")
|
||||
@@ -522,20 +529,20 @@ export function contentDispatch(funcName, args) {
|
||||
}
|
||||
if (/^h[1-6]$/.test(tag)) {
|
||||
const level = Number(tag.slice(1));
|
||||
const text = textBlock(node);
|
||||
const text = textBlock(el);
|
||||
return text ? `${"#".repeat(level)} ${text}` : "";
|
||||
}
|
||||
if (tag === "p" || tag === "figcaption") {
|
||||
return textBlock(node);
|
||||
return textBlock(el);
|
||||
}
|
||||
if (tag === "hr") {
|
||||
return "---";
|
||||
}
|
||||
if (tag === "img") {
|
||||
return inlineText(node);
|
||||
return inlineText(el);
|
||||
}
|
||||
|
||||
const childBlocks = Array.from(node.childNodes)
|
||||
const childBlocks = Array.from(el.childNodes)
|
||||
.map(child => blockToMarkdown(child))
|
||||
.filter(Boolean);
|
||||
if (childBlocks.length) return collapseBlankLines(childBlocks.join("\n\n"));
|
||||
@@ -552,7 +559,7 @@ export function contentDispatch(funcName, args) {
|
||||
domScroll, domSelect, domKey, domHover, domCheck, domClear, domFocus, domSubmit,
|
||||
pageInfo,
|
||||
extractLinks, extractImages, extractText, extractJson, extractMarkdown };
|
||||
const fn = fns[funcName];
|
||||
const fn = fns[funcName as keyof typeof fns];
|
||||
if (!fn) throw new Error(`Unknown content function: ${funcName}`);
|
||||
return fn(args);
|
||||
}
|
||||
|
||||
@@ -1,97 +1,113 @@
|
||||
// @ts-nocheck
|
||||
import { getActiveTab, getAliases, isBrowserErrorUrl, resolveGroupId, tabInfo } from '../core';
|
||||
export async function navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }) {
|
||||
let windowId;
|
||||
if (explicitWindowId != null) {
|
||||
windowId = explicitWindowId;
|
||||
} else if (windowName) {
|
||||
const aliases = await getAliases();
|
||||
const entry = Object.entries(aliases).find(([, v]) => v === windowName);
|
||||
if (entry) windowId = parseInt(entry[0]);
|
||||
}
|
||||
const tab = await chrome.tabs.create({ url, active: !background, windowId });
|
||||
if (groupNameOrId != null) {
|
||||
let groupId;
|
||||
try {
|
||||
groupId = await resolveGroupId(groupNameOrId);
|
||||
// Close any blank placeholder tabs that were created when the group was made
|
||||
const groupTabs = await chrome.tabs.query({ groupId });
|
||||
const placeholders = groupTabs.filter(t =>
|
||||
t.id !== tab.id &&
|
||||
(t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/")
|
||||
);
|
||||
await chrome.tabs.group({ tabIds: [tab.id], groupId });
|
||||
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
|
||||
} catch (e) {
|
||||
if (!e.message.startsWith("No tab group found")) throw e;
|
||||
// Group doesn't exist — create it with the tab already in it
|
||||
groupId = await chrome.tabs.group({ tabIds: [tab.id] });
|
||||
await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) });
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { NavOpenArgs, NavToArgs, NavTabArgs, NavFocusArgs, NavWaitArgs, NavOpenWaitArgs } from '../types';
|
||||
|
||||
export class NavigationCommands extends CommandGroup {
|
||||
readonly namespace = "navigate";
|
||||
readonly commands: Record<string, CommandEntry> = {
|
||||
"navigate.open": (a: NavOpenArgs) => this.navOpen(a),
|
||||
"navigate.to": (a: NavToArgs) => this.navTo(a),
|
||||
"navigate.reload": (a: NavTabArgs) => this.navReload(a, false),
|
||||
"navigate.hard_reload": (a: NavTabArgs) => this.navReload(a, true),
|
||||
"navigate.back": (a: NavTabArgs) => this.navBack(a),
|
||||
"navigate.forward": (a: NavTabArgs) => this.navForward(a),
|
||||
"navigate.focus": (a: NavFocusArgs) => this.navFocus(a),
|
||||
"navigate.wait": (a: NavWaitArgs) => this.navWait(a),
|
||||
"navigate.open_wait": (a: NavOpenWaitArgs) => this.navOpenWait(a),
|
||||
};
|
||||
|
||||
private async navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }: NavOpenArgs) {
|
||||
let windowId: number | undefined;
|
||||
if (explicitWindowId != null) {
|
||||
windowId = explicitWindowId;
|
||||
} else if (windowName) {
|
||||
const aliases = await getAliases();
|
||||
const entry = Object.entries(aliases).find(([, v]) => v === windowName);
|
||||
if (entry) windowId = parseInt(entry[0]);
|
||||
}
|
||||
}
|
||||
return { id: tab.id, url: tab.url };
|
||||
}
|
||||
|
||||
export async function navTo({ tabId, url }) {
|
||||
const tab = await chrome.tabs.update(tabId, { url });
|
||||
return { id: tab.id, url: tab.url || url };
|
||||
}
|
||||
|
||||
export async function navReload({ tabId }, bypassCache) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
await chrome.tabs.reload(tab.id, { bypassCache });
|
||||
return { tabId: tab.id };
|
||||
}
|
||||
|
||||
export async function navBack({ tabId }) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
await chrome.tabs.goBack(tab.id);
|
||||
return { tabId: tab.id };
|
||||
}
|
||||
|
||||
export async function navForward({ tabId }) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
await chrome.tabs.goForward(tab.id);
|
||||
return { tabId: tab.id };
|
||||
}
|
||||
|
||||
export async function navFocus({ pattern }) {
|
||||
// If pattern is a plain integer, treat it as a tab ID
|
||||
const asInt = parseInt(pattern);
|
||||
let match;
|
||||
if (!isNaN(asInt) && String(asInt) === String(pattern)) {
|
||||
match = await chrome.tabs.get(asInt);
|
||||
} else {
|
||||
const all = await chrome.tabs.query({});
|
||||
match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern)));
|
||||
}
|
||||
if (!match) return null;
|
||||
await chrome.windows.update(match.windowId, { focused: true });
|
||||
await chrome.tabs.update(match.id, { active: true });
|
||||
return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
|
||||
}
|
||||
|
||||
export async function navWait({ tabId, timeout = 30000, readyState = "complete" } = {}) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
const deadline = Date.now() + timeout;
|
||||
const interval = 200;
|
||||
while (Date.now() < deadline) {
|
||||
const t = await chrome.tabs.get(tab.id);
|
||||
const currentUrl = t.url || t.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(currentUrl)) {
|
||||
throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`);
|
||||
const tab = await chrome.tabs.create({ url, active: !background, windowId });
|
||||
if (groupNameOrId != null) {
|
||||
let groupId;
|
||||
try {
|
||||
groupId = await resolveGroupId(groupNameOrId);
|
||||
// Close any blank placeholder tabs that were created when the group was made
|
||||
const groupTabs = await chrome.tabs.query({ groupId });
|
||||
const placeholders = groupTabs.filter(t =>
|
||||
t.id !== tab.id &&
|
||||
(t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/")
|
||||
);
|
||||
await chrome.tabs.group({ tabIds: [tab.id], groupId });
|
||||
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error) || !e.message.startsWith("No tab group found")) throw e;
|
||||
// Group doesn't exist — create it with the tab already in it
|
||||
groupId = await chrome.tabs.group({ tabIds: [tab.id] });
|
||||
await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) });
|
||||
}
|
||||
}
|
||||
if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") {
|
||||
return tabInfo(t);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
return { id: tab.id, url: tab.url };
|
||||
}
|
||||
throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`);
|
||||
}
|
||||
|
||||
export async function navOpenWait({ url, timeout = 30000, background, window: windowName, group } = {}) {
|
||||
const opened = await navOpen({ url, background, window: windowName, group });
|
||||
return await navWait({ tabId: opened.id, timeout });
|
||||
}
|
||||
private async navTo({ tabId, url }: NavToArgs) {
|
||||
const tab = await chrome.tabs.update(tabId, { url });
|
||||
return { id: tab.id, url: tab.url || url };
|
||||
}
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
private async navReload({ tabId }: NavTabArgs, bypassCache: boolean) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
await chrome.tabs.reload(tab.id, { bypassCache });
|
||||
return { tabId: tab.id };
|
||||
}
|
||||
|
||||
private async navBack({ tabId }: NavTabArgs) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
await chrome.tabs.goBack(tab.id);
|
||||
return { tabId: tab.id };
|
||||
}
|
||||
|
||||
private async navForward({ tabId }: NavTabArgs) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
await chrome.tabs.goForward(tab.id);
|
||||
return { tabId: tab.id };
|
||||
}
|
||||
|
||||
private async navFocus({ pattern }: NavFocusArgs) {
|
||||
// If pattern is a plain integer, treat it as a tab ID
|
||||
const asInt = parseInt(pattern);
|
||||
let match: chrome.tabs.Tab | undefined;
|
||||
if (!isNaN(asInt) && String(asInt) === String(pattern)) {
|
||||
match = await chrome.tabs.get(asInt);
|
||||
} else {
|
||||
const all = await chrome.tabs.query({});
|
||||
match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern)));
|
||||
}
|
||||
if (!match) return null;
|
||||
await chrome.windows.update(match.windowId, { focused: true });
|
||||
await chrome.tabs.update(match.id, { active: true });
|
||||
return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
|
||||
}
|
||||
|
||||
private async navWait({ tabId, timeout = 30000, readyState = "complete" }: NavWaitArgs = {}) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
const deadline = Date.now() + timeout;
|
||||
const interval = 200;
|
||||
while (Date.now() < deadline) {
|
||||
const t = await chrome.tabs.get(tab.id);
|
||||
const currentUrl = t.url || t.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(currentUrl)) {
|
||||
throw new Error(`Tab ${tab.id} is showing an error page while waiting for load (${currentUrl})`);
|
||||
}
|
||||
if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") {
|
||||
return tabInfo(t);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
}
|
||||
throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`);
|
||||
}
|
||||
|
||||
private async navOpenWait({ url, timeout = 30000, background, window: windowName, group }: NavOpenWaitArgs = {}) {
|
||||
const opened = await this.navOpen({ url, background, window: windowName, group });
|
||||
return await this.navWait({ tabId: opened.id, timeout });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getLargeOperationThrottle, getPerformanceProfile, hasAudibleTabs, setPerformanceProfile } from '../core';
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { PerfSetProfileArgs, JobIdArgs } from '../types';
|
||||
|
||||
// PerfCommands also owns the jobs.* status/cancel queries: they read the same
|
||||
// JobManager (ctx.jobs) that perf.status reports, and there is no dedicated
|
||||
// jobs command group.
|
||||
export class PerfCommands extends CommandGroup {
|
||||
readonly namespace = "perf";
|
||||
readonly commands: Record<string, CommandEntry> = {
|
||||
"perf.status": () => this.perfStatus(),
|
||||
"perf.set_profile": (a: PerfSetProfileArgs) => setPerformanceProfile(typeof a.profile === "string" ? a.profile : undefined),
|
||||
"jobs.status": (a: JobIdArgs) => this.ctx.jobs.status(a),
|
||||
"jobs.cancel": (a: JobIdArgs) => this.ctx.jobs.cancel(a),
|
||||
};
|
||||
|
||||
private async perfStatus() {
|
||||
const profile = await getPerformanceProfile();
|
||||
const audible = await hasAudibleTabs();
|
||||
const throttle = await getLargeOperationThrottle(0, "auto");
|
||||
return {
|
||||
performanceProfile: profile,
|
||||
audible,
|
||||
throttle,
|
||||
jobs: this.ctx.jobs.list().map(job => ({
|
||||
id: job.id,
|
||||
command: job.command,
|
||||
status: job.status,
|
||||
phase: job.phase,
|
||||
current: job.current,
|
||||
total: job.total,
|
||||
percent: job.percent,
|
||||
cancelRequested: job.cancelRequested,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { normalizeGroupColor } from '../core';
|
||||
import type { SessionTab, StoredSession } from '../types';
|
||||
|
||||
export function buildSessionSnapshot(tabs: chrome.tabs.Tab[], groups: chrome.tabGroups.TabGroup[]): SessionTab[] {
|
||||
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: SessionTab = { 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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the current windows/tabs/groups into a ready-to-store session plus
|
||||
* its change-detection signature. Shared by session.save and the autosave path.
|
||||
*/
|
||||
export async function captureCurrentSession(): Promise<{ session: StoredSession; signature: string; tabCount: number }> {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const groups = await chrome.tabGroups.query({});
|
||||
const sessionTabs = buildSessionSnapshot(tabs, groups);
|
||||
const signature = sessionSignature(sessionTabs);
|
||||
const session: StoredSession = {
|
||||
tabs: sessionTabs,
|
||||
urls: sessionTabs.map(tab => tab.url),
|
||||
savedAt: Date.now(),
|
||||
signature,
|
||||
};
|
||||
return { session, signature, tabCount: sessionTabs.length };
|
||||
}
|
||||
|
||||
export function sessionSignature(sessionTabs: SessionTab[]) {
|
||||
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,
|
||||
})));
|
||||
}
|
||||
+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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { fetchTabHtml, getActiveTab, getAliases, isBrowserErrorUrl, tabInfo } from '../core';
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { TabIdArgs, TabsActiveInWindowArgs, TabsPatternArgs, TabsQueryArgs, TabsWatchUrlArgs } from '../types';
|
||||
|
||||
export class TabsQueryCommands extends CommandGroup {
|
||||
readonly namespace = "tabs";
|
||||
readonly commands: Record<string, CommandEntry> = {
|
||||
"tabs.list": () => this.tabsList(),
|
||||
"tabs.active_in_window": (a: TabsActiveInWindowArgs) => this.tabsActiveInWindow(a),
|
||||
"tabs.status": (a: TabIdArgs) => this.tabsStatus(a),
|
||||
"tabs.filter": (a: TabsPatternArgs) => this.tabsFilter(a),
|
||||
"tabs.count": (a: TabsPatternArgs) => this.tabsCount(a),
|
||||
"tabs.query": (a: TabsQueryArgs) => this.tabsQuery(a),
|
||||
"tabs.html": (a: TabIdArgs) => fetchTabHtml(a.tabId),
|
||||
"tabs.watch_url": (a: TabsWatchUrlArgs) => this.tabsWatchUrl(a),
|
||||
};
|
||||
|
||||
private async tabsList() {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const aliases = await getAliases();
|
||||
const tabs = [];
|
||||
for (const w of windows) {
|
||||
for (const t of w.tabs) {
|
||||
tabs.push({
|
||||
...tabInfo(t),
|
||||
windowAlias: aliases[t.windowId] || null,
|
||||
pinned: t.pinned,
|
||||
favIconUrl: t.favIconUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
private async tabsActiveInWindow({ windowId }: TabsActiveInWindowArgs) {
|
||||
const activeTabs = await chrome.tabs.query({ windowId, active: true });
|
||||
const tab = activeTabs[0];
|
||||
if (!tab) {
|
||||
throw new Error(`No active tab found for window ${windowId}`);
|
||||
}
|
||||
return tabInfo(tab);
|
||||
}
|
||||
|
||||
private async tabsStatus({ tabId }: TabIdArgs) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
return tabInfo(tab);
|
||||
}
|
||||
|
||||
private async tabsFilter({ pattern }: TabsPatternArgs) {
|
||||
const all = await chrome.tabs.query({});
|
||||
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
|
||||
}
|
||||
|
||||
private async tabsCount({ pattern }: TabsPatternArgs) {
|
||||
const all = await chrome.tabs.query({});
|
||||
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
|
||||
return all.length;
|
||||
}
|
||||
|
||||
private async tabsQuery({ search }: TabsQueryArgs) {
|
||||
const q = search.toLowerCase();
|
||||
const all = await chrome.tabs.query({});
|
||||
return all.filter(t =>
|
||||
(t.url && t.url.toLowerCase().includes(q)) ||
|
||||
(t.title && t.title.toLowerCase().includes(q))
|
||||
).map(tabInfo);
|
||||
}
|
||||
|
||||
private async tabsWatchUrl({ pattern, timeout = 30000, tabId }: TabsWatchUrlArgs = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const deadline = Date.now() + timeout;
|
||||
const regex = new RegExp(pattern);
|
||||
let lastUrl = tab.url || tab.pendingUrl || "";
|
||||
let lastStatus = tab.status || "unknown";
|
||||
|
||||
const matches = (url: string) => {
|
||||
regex.lastIndex = 0;
|
||||
return Boolean(url && regex.test(url));
|
||||
};
|
||||
if (matches(lastUrl)) return tabInfo(tab);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const t = await chrome.tabs.get(tab.id);
|
||||
lastUrl = t.url || t.pendingUrl || "";
|
||||
lastStatus = t.status || "unknown";
|
||||
if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t);
|
||||
if (isBrowserErrorUrl(t.url || "")) {
|
||||
throw new Error(`Tab ${tab.id} is showing an error page while waiting for URL to match '${pattern}'`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms (last URL: '${lastUrl}', status: '${lastStatus}')`);
|
||||
}
|
||||
}
|
||||
+159
-255
@@ -1,262 +1,166 @@
|
||||
// @ts-nocheck
|
||||
import { executeScript, getActiveTab, getAliases, getLargeOperationThrottle, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl, resolveTabForDirectAction, runLargeOperation, tabInfo, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
|
||||
export async function tabsList() {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const aliases = await getAliases();
|
||||
const tabs = [];
|
||||
for (const w of windows) {
|
||||
for (const t of w.tabs) {
|
||||
tabs.push({
|
||||
...tabInfo(t),
|
||||
windowAlias: aliases[t.windowId] || null,
|
||||
pinned: t.pinned,
|
||||
favIconUrl: t.favIconUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
import { asTabIds, getActiveTab, getLargeOperationThrottle, processInBatches, resolveTabForDirectAction, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { TabsCloseArgs, TabsMoveArgs, TabIdArgs, TabsSortArgs, TabsMergeWindowsArgs, TabsScreenshotArgs } from '../types';
|
||||
|
||||
export async function tabsClose({ tabId, inactive, duplicates, gentleMode, __job } = {}) {
|
||||
return runLargeOperation("tabs.close", async () => {
|
||||
let toClose = [];
|
||||
if (duplicates) {
|
||||
const all = await chrome.tabs.query({});
|
||||
const seen = new Set();
|
||||
for (const t of all) {
|
||||
if (!t.url) continue;
|
||||
if (seen.has(t.url)) toClose.push(t.id);
|
||||
else seen.add(t.url);
|
||||
}
|
||||
} else if (inactive) {
|
||||
const all = await chrome.tabs.query({});
|
||||
toClose = all.filter(t => !t.active).map(t => t.id);
|
||||
} else if (tabId) {
|
||||
toClose = [tabId];
|
||||
}
|
||||
const throttle = await getLargeOperationThrottle(toClose.length, gentleMode);
|
||||
updateJobProgress(__job, { phase: "closing tabs", current: 0, total: toClose.length });
|
||||
for (let i = 0; i < toClose.length; i += throttle.batchSize) {
|
||||
throwIfJobCancelled(__job);
|
||||
await chrome.tabs.remove(toClose.slice(i, i + throttle.batchSize));
|
||||
updateJobProgress(__job, { phase: "closing tabs", current: Math.min(i + throttle.batchSize, toClose.length), total: toClose.length });
|
||||
await yieldForLargeOperation(i + throttle.batchSize, throttle.batchSize, throttle.pauseMs);
|
||||
}
|
||||
return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible };
|
||||
});
|
||||
}
|
||||
|
||||
export async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) {
|
||||
const moveProps = {};
|
||||
if (windowId != null) moveProps.windowId = windowId;
|
||||
|
||||
if (forward || backward) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal
|
||||
else moveProps.index = Math.max(0, tab.index - 1);
|
||||
} else if (index != null) {
|
||||
moveProps.index = index;
|
||||
} else {
|
||||
moveProps.index = -1;
|
||||
}
|
||||
|
||||
await chrome.tabs.move(tabId, moveProps);
|
||||
if (groupId != null) {
|
||||
await chrome.tabs.group({ tabIds: [tabId], groupId });
|
||||
}
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
export async function tabsActive({ tabId }) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
await chrome.windows.update(tab.windowId, { focused: true });
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
export async function tabsActiveInWindow({ windowId }) {
|
||||
const activeTabs = await chrome.tabs.query({ windowId, active: true });
|
||||
const tab = activeTabs[0];
|
||||
if (!tab) {
|
||||
throw new Error(`No active tab found for window ${windowId}`);
|
||||
}
|
||||
return tabInfo(tab);
|
||||
}
|
||||
|
||||
export async function tabsStatus({ tabId }) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
return tabInfo(tab);
|
||||
}
|
||||
|
||||
export async function tabsFilter({ pattern }) {
|
||||
const all = await chrome.tabs.query({});
|
||||
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
|
||||
}
|
||||
|
||||
export async function tabsCount({ pattern }) {
|
||||
const all = await chrome.tabs.query({});
|
||||
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
|
||||
return all.length;
|
||||
}
|
||||
|
||||
export async function tabsQuery({ search }) {
|
||||
const q = search.toLowerCase();
|
||||
const all = await chrome.tabs.query({});
|
||||
return all.filter(t =>
|
||||
(t.url && t.url.toLowerCase().includes(q)) ||
|
||||
(t.title && t.title.toLowerCase().includes(q))
|
||||
).map(tabInfo);
|
||||
}
|
||||
|
||||
export async function tabsHtml({ tabId }) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
return "";
|
||||
}
|
||||
if (!isScriptableUrl(tabUrl)) {
|
||||
throw new Error(`Cannot get HTML of ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => document.documentElement.outerHTML,
|
||||
});
|
||||
return results[0]?.result || "";
|
||||
} catch (e) {
|
||||
if (isErrorPageScriptError(e)) return "";
|
||||
const transient = e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id"));
|
||||
if (i < 2 && transient) {
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function tabsDedupe(args = {}) {
|
||||
return tabsClose({ ...args, duplicates: true });
|
||||
}
|
||||
|
||||
export async function tabsSort({ by, gentleMode, __job } = {}) {
|
||||
return runLargeOperation("tabs.sort", async () => {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
let moved = 0;
|
||||
const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
|
||||
updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs });
|
||||
for (const w of windows) {
|
||||
const sorted = [...w.tabs].sort((a, b) => {
|
||||
if (by === "title") return (a.title || "").localeCompare(b.title || "");
|
||||
if (by === "time") return a.id - b.id; // lower id = opened earlier
|
||||
// domain (default)
|
||||
const da = new URL(a.url || a.pendingUrl || "about:blank").hostname;
|
||||
const db = new URL(b.url || b.pendingUrl || "about:blank").hostname;
|
||||
return da.localeCompare(db);
|
||||
});
|
||||
if (w.tabs.every((tab, index) => tab.id === sorted[index]?.id)) continue;
|
||||
const throttle = await getLargeOperationThrottle(sorted.length, gentleMode);
|
||||
const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
throwIfJobCancelled(__job);
|
||||
await chrome.tabs.move(sorted[i].id, { index: i });
|
||||
moved++;
|
||||
updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs });
|
||||
await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs);
|
||||
}
|
||||
}
|
||||
return { moved };
|
||||
});
|
||||
}
|
||||
|
||||
export async function tabsMergeWindows({ gentleMode, __job } = {}) {
|
||||
return runLargeOperation("tabs.merge_windows", async () => {
|
||||
const current = await chrome.windows.getCurrent();
|
||||
const all = await chrome.windows.getAll({ populate: true });
|
||||
let moved = 0;
|
||||
const totalTabs = all.filter(w => w.id !== current.id).reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
|
||||
updateJobProgress(__job, { phase: "merging windows", current: 0, total: totalTabs });
|
||||
for (const w of all) {
|
||||
if (w.id === current.id) continue;
|
||||
const ids = w.tabs.map(t => t.id);
|
||||
const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
|
||||
for (let i = 0; i < ids.length; i += throttle.batchSize) {
|
||||
throwIfJobCancelled(__job);
|
||||
const chunk = ids.slice(i, i + throttle.batchSize);
|
||||
await chrome.tabs.move(chunk, { windowId: current.id, index: -1 });
|
||||
moved += chunk.length;
|
||||
updateJobProgress(__job, { phase: "merging windows", current: moved, total: totalTabs });
|
||||
await yieldForLargeOperation(moved, throttle.batchSize, throttle.pauseMs);
|
||||
}
|
||||
}
|
||||
return { moved };
|
||||
});
|
||||
}
|
||||
|
||||
export async function tabsPin({ tabId }) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: true });
|
||||
return { tabId: tab.id, pinned: true };
|
||||
}
|
||||
|
||||
export async function tabsUnpin({ tabId }) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: false });
|
||||
return { tabId: tab.id, pinned: false };
|
||||
}
|
||||
|
||||
export async function tabsScreenshot({ tabId, format = "png", quality } = {}) {
|
||||
let windowId;
|
||||
if (tabId) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
windowId = tab.windowId;
|
||||
} else {
|
||||
const tab = await getActiveTab();
|
||||
windowId = tab.windowId;
|
||||
}
|
||||
const opts = { format };
|
||||
if (format === "jpeg" && quality != null) opts.quality = quality;
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts);
|
||||
return { dataUrl, format };
|
||||
}
|
||||
|
||||
export async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const deadline = Date.now() + timeout;
|
||||
const regex = new RegExp(pattern);
|
||||
let lastUrl = tab.url || tab.pendingUrl || "";
|
||||
let lastStatus = tab.status || "unknown";
|
||||
|
||||
const matches = (url) => {
|
||||
regex.lastIndex = 0;
|
||||
return Boolean(url && regex.test(url));
|
||||
export class TabsMutationCommands extends CommandGroup {
|
||||
readonly namespace = "tabs";
|
||||
readonly commands: Record<string, CommandEntry> = {
|
||||
"tabs.close": { background: true, run: (a: TabsCloseArgs) => this.tabsClose(a) },
|
||||
"tabs.move": (a: TabsMoveArgs) => this.tabsMove(a),
|
||||
"tabs.active": (a: TabIdArgs) => this.tabsActive(a),
|
||||
"tabs.dedupe": { background: true, run: (a: TabsCloseArgs) => this.tabsDedupe(a) },
|
||||
"tabs.sort": { background: true, run: (a: TabsSortArgs) => this.tabsSort(a) },
|
||||
"tabs.merge_windows": { background: true, run: (a: TabsMergeWindowsArgs) => this.tabsMergeWindows(a) },
|
||||
"tabs.mute": (a: TabIdArgs) => this.tabsMute(a),
|
||||
"tabs.unmute": (a: TabIdArgs) => this.tabsUnmute(a),
|
||||
"tabs.pin": (a: TabIdArgs) => this.tabsPin(a),
|
||||
"tabs.unpin": (a: TabIdArgs) => this.tabsUnpin(a),
|
||||
"tabs.screenshot": (a: TabsScreenshotArgs) => this.tabsScreenshot(a),
|
||||
};
|
||||
if (matches(lastUrl)) return tabInfo(tab);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const t = await chrome.tabs.get(tab.id);
|
||||
lastUrl = t.url || t.pendingUrl || "";
|
||||
lastStatus = t.status || "unknown";
|
||||
if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t);
|
||||
if (isBrowserErrorUrl(t.url || "")) {
|
||||
throw new Error(`Tab ${tab.id} is showing an error page while waiting for URL to match '${pattern}'`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
private async tabsClose({ tabId, inactive, duplicates, gentleMode, __job }: TabsCloseArgs = {}) {
|
||||
return runLargeOperation("tabs.close", async () => {
|
||||
let toClose: number[] = [];
|
||||
if (duplicates) {
|
||||
const all = await chrome.tabs.query({});
|
||||
const seen = new Set();
|
||||
for (const t of all) {
|
||||
if (!t.url) continue;
|
||||
if (seen.has(t.url)) toClose.push(t.id);
|
||||
else seen.add(t.url);
|
||||
}
|
||||
} else if (inactive) {
|
||||
const all = await chrome.tabs.query({});
|
||||
toClose = all.filter(t => !t.active).map(t => t.id);
|
||||
} else if (tabId) {
|
||||
toClose = [tabId];
|
||||
}
|
||||
const throttle = await getLargeOperationThrottle(toClose.length, gentleMode);
|
||||
await processInBatches(toClose, throttle, batch => chrome.tabs.remove(batch), { job: __job, phase: "closing tabs" });
|
||||
return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible };
|
||||
});
|
||||
}
|
||||
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms (last URL: '${lastUrl}', status: '${lastStatus}')`);
|
||||
}
|
||||
|
||||
export async function tabsMute({ tabId }) {
|
||||
const tab = await resolveTabForDirectAction(tabId, "mute");
|
||||
await chrome.tabs.update(tab.id, { muted: true });
|
||||
return { tabId: tab.id, muted: true };
|
||||
}
|
||||
private async tabsMove({ tabId, groupId, windowId, index, forward, backward }: TabsMoveArgs) {
|
||||
const moveProps: Partial<chrome.tabs.MoveProperties> = {};
|
||||
if (windowId != null) moveProps.windowId = windowId;
|
||||
|
||||
export async function tabsUnmute({ tabId }) {
|
||||
const tab = await resolveTabForDirectAction(tabId, "unmute");
|
||||
await chrome.tabs.update(tab.id, { muted: false });
|
||||
return { tabId: tab.id, muted: false };
|
||||
}
|
||||
if (forward || backward) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal
|
||||
else moveProps.index = Math.max(0, tab.index - 1);
|
||||
} else if (index != null) {
|
||||
moveProps.index = index;
|
||||
} else {
|
||||
moveProps.index = -1;
|
||||
}
|
||||
|
||||
// `index` is always assigned by one of the branches above before this call.
|
||||
await chrome.tabs.move(tabId, moveProps as chrome.tabs.MoveProperties);
|
||||
if (groupId != null) {
|
||||
await chrome.tabs.group({ tabIds: asTabIds([tabId]), groupId });
|
||||
}
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
private async tabsActive({ tabId }: TabIdArgs) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
await chrome.windows.update(tab.windowId, { focused: true });
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
private async tabsDedupe(args: TabsCloseArgs = {}) {
|
||||
return this.tabsClose({ ...args, duplicates: true });
|
||||
}
|
||||
|
||||
private async tabsSort({ by, gentleMode, __job }: TabsSortArgs = {}) {
|
||||
return runLargeOperation("tabs.sort", async () => {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
let moved = 0;
|
||||
const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
|
||||
updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs });
|
||||
for (const w of windows) {
|
||||
const sorted = [...w.tabs].sort((a, b) => {
|
||||
if (by === "title") return (a.title || "").localeCompare(b.title || "");
|
||||
if (by === "time") return a.id - b.id; // lower id = opened earlier
|
||||
// domain (default)
|
||||
const da = new URL(a.url || a.pendingUrl || "about:blank").hostname;
|
||||
const db = new URL(b.url || b.pendingUrl || "about:blank").hostname;
|
||||
return da.localeCompare(db);
|
||||
});
|
||||
if (w.tabs.every((tab, index) => tab.id === sorted[index]?.id)) continue;
|
||||
const throttle = await getLargeOperationThrottle(sorted.length, gentleMode);
|
||||
const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
throwIfJobCancelled(__job);
|
||||
await chrome.tabs.move(sorted[i].id, { index: i });
|
||||
moved++;
|
||||
updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs });
|
||||
await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs);
|
||||
}
|
||||
}
|
||||
return { moved };
|
||||
});
|
||||
}
|
||||
|
||||
private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) {
|
||||
return runLargeOperation("tabs.merge_windows", async () => {
|
||||
const current = await chrome.windows.getCurrent();
|
||||
const all = await chrome.windows.getAll({ populate: true });
|
||||
let moved = 0;
|
||||
const totalTabs = all.filter(w => w.id !== current.id).reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
|
||||
updateJobProgress(__job, { phase: "merging windows", current: 0, total: totalTabs });
|
||||
for (const w of all) {
|
||||
if (w.id === current.id) continue;
|
||||
const ids = w.tabs.map(t => t.id);
|
||||
const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
|
||||
moved = await processInBatches(ids, throttle,
|
||||
batch => chrome.tabs.move(batch, { windowId: current.id, index: -1 }),
|
||||
{ job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved });
|
||||
}
|
||||
return { moved };
|
||||
});
|
||||
}
|
||||
|
||||
private async tabsPin({ tabId }: TabIdArgs) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: true });
|
||||
return { tabId: tab.id, pinned: true };
|
||||
}
|
||||
|
||||
private async tabsUnpin({ tabId }: TabIdArgs) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: false });
|
||||
return { tabId: tab.id, pinned: false };
|
||||
}
|
||||
|
||||
private async tabsScreenshot({ tabId, format = "png", quality }: TabsScreenshotArgs = {}) {
|
||||
let windowId: number | undefined;
|
||||
if (tabId) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
windowId = tab.windowId;
|
||||
} else {
|
||||
const tab = await getActiveTab();
|
||||
windowId = tab.windowId;
|
||||
}
|
||||
const opts: chrome.extensionTypes.ImageDetails = { format: format as chrome.extensionTypes.ImageFormat };
|
||||
if (format === "jpeg" && quality != null) opts.quality = quality;
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts);
|
||||
return { dataUrl, format };
|
||||
}
|
||||
|
||||
private async tabsMute({ tabId }: TabIdArgs) {
|
||||
const tab = await resolveTabForDirectAction(tabId, "mute");
|
||||
await chrome.tabs.update(tab.id, { muted: true });
|
||||
return { tabId: tab.id, muted: true };
|
||||
}
|
||||
|
||||
private async tabsUnmute({ tabId }: TabIdArgs) {
|
||||
const tab = await resolveTabForDirectAction(tabId, "unmute");
|
||||
await chrome.tabs.update(tab.id, { muted: false });
|
||||
return { tabId: tab.id, muted: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,45 @@
|
||||
// @ts-nocheck
|
||||
import { getAliases } from '../core';
|
||||
export async function windowsList() {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const aliases = await getAliases();
|
||||
return windows.map(w => ({
|
||||
id: w.id,
|
||||
alias: aliases[w.id] || null,
|
||||
focused: w.focused,
|
||||
state: w.state,
|
||||
tabCount: (w.tabs || []).length,
|
||||
}));
|
||||
}
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { WindowsRenameArgs, WindowsCloseArgs, WindowsOpenArgs } from '../types';
|
||||
|
||||
export async function windowsRename({ windowId, name }) {
|
||||
const aliases = await getAliases();
|
||||
aliases[windowId] = name;
|
||||
await chrome.storage.local.set({ windowAliases: aliases });
|
||||
return { windowId, name };
|
||||
}
|
||||
export class WindowsCommands extends CommandGroup {
|
||||
readonly namespace = "windows";
|
||||
readonly commands: Record<string, CommandEntry> = {
|
||||
"windows.list": () => this.windowsList(),
|
||||
"windows.rename": (a: WindowsRenameArgs) => this.windowsRename(a),
|
||||
"windows.close": (a: WindowsCloseArgs) => this.windowsClose(a),
|
||||
"windows.open": (a: WindowsOpenArgs) => this.windowsOpen(a),
|
||||
};
|
||||
|
||||
export async function windowsClose({ windowId }) {
|
||||
await chrome.windows.remove(windowId);
|
||||
return { windowId };
|
||||
}
|
||||
private async windowsList() {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const aliases = await getAliases();
|
||||
return windows.map(w => ({
|
||||
id: w.id,
|
||||
alias: aliases[w.id] || null,
|
||||
focused: w.focused,
|
||||
state: w.state,
|
||||
tabCount: (w.tabs || []).length,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function windowsOpen({ url }) {
|
||||
const createData = { focused: true };
|
||||
if (url) createData.url = url;
|
||||
const w = await chrome.windows.create(createData);
|
||||
return { id: w.id };
|
||||
}
|
||||
private async windowsRename({ windowId, name }: WindowsRenameArgs) {
|
||||
const aliases = await getAliases();
|
||||
aliases[windowId] = name;
|
||||
await chrome.storage.local.set({ windowAliases: aliases });
|
||||
return { windowId, name };
|
||||
}
|
||||
|
||||
// ── DOM / Extract ─────────────────────────────────────────────────────────────
|
||||
private async windowsClose({ windowId }: WindowsCloseArgs) {
|
||||
await chrome.windows.remove(windowId);
|
||||
return { windowId };
|
||||
}
|
||||
|
||||
private async windowsOpen({ url }: WindowsOpenArgs) {
|
||||
const createData: chrome.windows.CreateData = { focused: true };
|
||||
if (url) createData.url = url;
|
||||
const w = await chrome.windows.create(createData);
|
||||
return { id: w.id };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user