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 class TabsMutationCommands extends CommandGroup { readonly namespace = "tabs"; readonly commands: Record = { "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), }; private async tabsClose({ tabId, tabIds, inactive, duplicates, gentleMode, __job }: TabsCloseArgs = {}) { return runLargeOperation("tabs.close", async () => { let toClose: number[] = []; if (duplicates) { const windows = await chrome.windows.getAll({ populate: true }); const seen = new Set(); for (const w of windows) { for (const t of w.tabs || []) { const url = t.url || t.pendingUrl; if (!url || t.id == null) continue; if (seen.has(url)) toClose.push(t.id); else seen.add(url); } } } else if (inactive) { const all = await chrome.tabs.query({}); toClose = all.filter(t => !t.active).map(t => t.id); } else if (tabIds?.length) { toClose = tabIds.filter(id => id != null); } 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 }; }); } private async tabsMove({ tabId, groupId, windowId, index, forward, backward }: TabsMoveArgs) { const moveProps: Partial = {}; 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; } // `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) { 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 windowHasAudibleTabs(window: chrome.windows.Window): boolean { return Boolean(window.tabs?.some(tab => tab.audible && !tab.mutedInfo?.muted)); } private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) { return runLargeOperation("tabs.merge_windows", async () => { const all = await chrome.windows.getAll({ populate: true }); const movableWindows = all.filter(w => !this.windowHasAudibleTabs(w)); const target = movableWindows.find(w => w.focused) || movableWindows[0]; if (!target) return { moved: 0, skippedAudibleWindows: all.length }; let moved = 0; const totalTabs = movableWindows.filter(w => w.id !== target.id).reduce((sum, w) => sum + (w.tabs?.length || 0), 0); updateJobProgress(__job, { phase: "merging windows", current: 0, total: totalTabs }); for (const w of movableWindows) { if (w.id === target.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: target.id, index: -1 }), { job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved }); } return { moved, skippedAudibleWindows: all.length - movableWindows.length }; }); } 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 }; } }