From 0813ae2de93c23c2b75f3f0ad19a1be7f6666a19 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Thu, 11 Jun 2026 10:31:53 +0200 Subject: [PATCH] feat(tabs): batch-close tabs by id list Closing many tabs previously meant one IPC round-trip per tab (tab.close() in a loop). Add a single batched path so callers can close N tabs in one command, reusing the existing large-operation throttle so the browser UI stays responsive. - extension: tabs.close accepts tabIds: number[]; new branch feeds the array through processInBatches/chrome.tabs.remove - sdk: tabs_close(tab_ids=...) takes tab IDs or Tab objects; the payload always carries "tabIds" (null when unused) - tests: cover id-list and Tab-object batch close in test_api.py - bump 0.10.3 -> 0.10.4 (pyproject.toml, manifest.json) --- browser_cli/__init__.py | 26 +++++++++++++++++++++++--- extension/manifest.json | 2 +- extension/src/commands/tabs.ts | 4 +++- extension/src/types/command-args.ts | 2 +- pyproject.toml | 2 +- tests/test_api.py | 25 ++++++++++++++++++++++--- 6 files changed, 51 insertions(+), 10 deletions(-) diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py index 0186a77..230587a 100644 --- a/browser_cli/__init__.py +++ b/browser_cli/__init__.py @@ -308,9 +308,29 @@ class BrowserCLI: ] return [self._make_tab(t) for t in (self._cmd("tabs.list", {}) or [])] - def tabs_close(self, tab_id: int | None = None, *, inactive: bool = False, duplicates: bool = False) -> int: - """Close tab(s). Returns the number of tabs closed.""" - result = self._cmd("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates}) + def tabs_close( + self, + tab_id: int | None = None, + *, + tab_ids: Iterable[int | Tab] | None = None, + inactive: bool = False, + duplicates: bool = False, + ) -> int: + """Close tab(s). Returns the number of tabs closed. + + Pass ``tab_ids`` to close many tabs in a single round-trip instead of + calling :meth:`close_tab` per tab. Accepts tab IDs or :class:`Tab` + objects. The extension throttles large batches automatically. + """ + ids = None + if tab_ids is not None: + ids = [t.id if isinstance(t, Tab) else t for t in tab_ids] + result = self._cmd("tabs.close", { + "tabId": tab_id, + "tabIds": ids, + "inactive": inactive, + "duplicates": duplicates, + }) return result.get("closed", 1) if isinstance(result, dict) else 1 def tabs_move( diff --git a/extension/manifest.json b/extension/manifest.json index 7163d09..9a80817 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.10.3", + "version": "0.10.4", "description": "Control your browser from the terminal or Python SDK", "permissions": [ "tabs", diff --git a/extension/src/commands/tabs.ts b/extension/src/commands/tabs.ts index 78789b5..0e5eb4e 100644 --- a/extension/src/commands/tabs.ts +++ b/extension/src/commands/tabs.ts @@ -19,7 +19,7 @@ export class TabsMutationCommands extends CommandGroup { "tabs.screenshot": (a: TabsScreenshotArgs) => this.tabsScreenshot(a), }; - private async tabsClose({ tabId, inactive, duplicates, gentleMode, __job }: TabsCloseArgs = {}) { + private async tabsClose({ tabId, tabIds, inactive, duplicates, gentleMode, __job }: TabsCloseArgs = {}) { return runLargeOperation("tabs.close", async () => { let toClose: number[] = []; if (duplicates) { @@ -33,6 +33,8 @@ export class TabsMutationCommands extends CommandGroup { } 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]; } diff --git a/extension/src/types/command-args.ts b/extension/src/types/command-args.ts index 1ed9e1e..c023837 100644 --- a/extension/src/types/command-args.ts +++ b/extension/src/types/command-args.ts @@ -23,7 +23,7 @@ export interface NavOpenWaitArgs { } // ── Tabs ────────────────────────────────────────────────────────────────────── -export interface TabsCloseArgs { tabId?: number; inactive?: boolean; duplicates?: boolean; gentleMode?: string; __job?: Job; } +export interface TabsCloseArgs { tabId?: number; tabIds?: number[]; inactive?: boolean; duplicates?: boolean; gentleMode?: string; __job?: Job; } export interface TabsMoveArgs { tabId?: number; groupId?: number; windowId?: number; index?: number; forward?: boolean; backward?: boolean; } export interface TabIdArgs { tabId?: number; } export interface TabsActiveInWindowArgs { windowId?: number; } diff --git a/pyproject.toml b/pyproject.toml index 234ca36..116f09e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.10.3" +version = "0.10.4" description = "Control your real running browser from the terminal or Python SDK" requires-python = ">=3.10" dependencies = [ diff --git a/tests/test_api.py b/tests/test_api.py index 6494619..e497afd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -305,7 +305,26 @@ class TestTabs: b.tabs_close(tab_id=10) mock_send.assert_called_once_with( "tabs.close", - {"tabId": 10, "inactive": False, "duplicates": False}, + {"tabId": 10, "tabIds": None, "inactive": False, "duplicates": False}, + profile=None, remote=None, key=None, + ) + + def test_tabs_close_by_ids(self, b, mock_send): + mock_send.return_value = {"closed": 3} + assert b.tabs_close(tab_ids=[10, 20, 30]) == 3 + mock_send.assert_called_once_with( + "tabs.close", + {"tabId": None, "tabIds": [10, 20, 30], "inactive": False, "duplicates": False}, + profile=None, remote=None, key=None, + ) + + def test_tabs_close_by_ids_accepts_tab_objects(self, b, mock_send): + tabs = [b._make_tab({**TAB_DATA, "id": 10}), b._make_tab({**TAB_DATA, "id": 20})] + mock_send.return_value = {"closed": 2} + assert b.tabs_close(tab_ids=tabs) == 2 + mock_send.assert_called_once_with( + "tabs.close", + {"tabId": None, "tabIds": [10, 20], "inactive": False, "duplicates": False}, profile=None, remote=None, key=None, ) @@ -446,8 +465,8 @@ class TestTabs: call("tabs.status", {"tabId": 10}, profile=None, remote=None, key=None), call("tabs.query", {"search": "Example"}, profile=None, remote=None, key=None), call("tabs.query", {"search": "Missing"}, profile=None, remote=None, key=None), - call("tabs.close", {"tabId": 10, "inactive": False, "duplicates": False}, profile=None, remote=None, key=None), - call("tabs.close", {"tabId": 10, "inactive": False, "duplicates": False}, profile=None, remote=None, key=None), + call("tabs.close", {"tabId": 10, "tabIds": None, "inactive": False, "duplicates": False}, profile=None, remote=None, key=None), + call("tabs.close", {"tabId": 10, "tabIds": None, "inactive": False, "duplicates": False}, profile=None, remote=None, key=None), ] def test_find_tabs_alias(self, b, mock_send):