509f1387de
- Respect the globally selected browser when renaming client aliases. - Pass the resolved local profile into sync and async local transports so BROWSER_CLI_PROFILE is honored consistently. - Stop tabs.active from explicitly focusing the OS browser window, avoiding virtual-desktop jumps during tab activation. - Make window merging skip audible, unmuted windows so video playback windows are not selected as merge targets. - Bump the Python package and extension manifest versions to 0.12.2. - Add regression coverage for browser selection and focus-stealing behavior.
177 lines
8.1 KiB
TypeScript
177 lines
8.1 KiB
TypeScript
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<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),
|
|
};
|
|
|
|
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<string>();
|
|
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<chrome.tabs.MoveProperties> = {};
|
|
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 };
|
|
}
|
|
}
|