feat(tabs): reuse an open tab instead of duplicating a page
- Add browser_cli/url_match.py with a canonical URL key for tab matching - Canonicalize scheme, host, default ports, www., trailing slash, query order - Support reuse modes none, exact, similar and origin via normalize_mode() - Pick the closest match when several tabs qualify, preferring identical URLs - Skip the reload when the reused tab already shows the requested page - Expose nav.reusable_tab() so callers can look up a tab without side effects - Accept reuse/reuse_domain/reuse_title in tabs.open(), not just nav.open() - Add --reuse-exact to nav open and nav open-wait; --reuse is now fuzzy - Default MCP tabs_open to reuse="similar" and return a reused flag - Tell MCP agents in the server instructions to reuse before opening - Document the reuse modes in README for MCP, CLI and Python API - Cover url_match, SDK reuse paths and MCP reuse behaviour with new tests - Bump package and extension version to 0.16.9 - Refresh uv.lock for the updated dependency set
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from browser_cli.models import Tab
|
||||
from browser_cli.sdk.base import Namespace, sdk_command
|
||||
from browser_cli.url_match import find_reusable_tab, needs_navigation, normalize_mode
|
||||
|
||||
def _open_args(self, url, *, background=False, focus=False, window=None, group=None, **_ignored):
|
||||
return {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}
|
||||
@@ -21,18 +22,20 @@ class NavigationNS(Namespace):
|
||||
focus: bool = False,
|
||||
window: str | None = None,
|
||||
group: str | None = None,
|
||||
reuse: bool = False,
|
||||
reuse: bool | str = False,
|
||||
reuse_domain: bool = False,
|
||||
reuse_title: str | None = None,
|
||||
) -> None:
|
||||
"""Open *url* in a new tab without stealing OS focus by default.
|
||||
|
||||
``reuse``/``reuse_domain``/``reuse_title`` navigate an existing matching tab
|
||||
instead of creating a new one.
|
||||
instead of creating a new one. ``reuse=True`` matches nearly identical URLs;
|
||||
pass ``reuse='exact'`` or ``reuse='origin'`` to tighten or widen that.
|
||||
"""
|
||||
tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
|
||||
tab = self.reusable_tab(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
|
||||
if tab is not None:
|
||||
self.to(tab.id, url)
|
||||
if needs_navigation(tab.url, url):
|
||||
self.to(tab.id, url)
|
||||
if focus:
|
||||
self._c.tabs.activate(tab.id)
|
||||
return None
|
||||
@@ -48,14 +51,15 @@ class NavigationNS(Namespace):
|
||||
focus: bool = False,
|
||||
window: str | None = None,
|
||||
group: str | None = None,
|
||||
reuse: bool = False,
|
||||
reuse: bool | str = False,
|
||||
reuse_domain: bool = False,
|
||||
reuse_title: str | None = None,
|
||||
) -> Tab:
|
||||
"""Open *url* in a new tab and block until fully loaded. Returns the Tab."""
|
||||
tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
|
||||
tab = self.reusable_tab(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
|
||||
if tab is not None:
|
||||
self.to(tab.id, url)
|
||||
if needs_navigation(tab.url, url):
|
||||
self.to(tab.id, url)
|
||||
if focus:
|
||||
self._c.tabs.activate(tab.id)
|
||||
return self._c.tabs.wait_for_load(tab.id, timeout=timeout)
|
||||
@@ -91,21 +95,31 @@ class NavigationNS(Namespace):
|
||||
def to(self, tab_id: int, url: str) -> None:
|
||||
"""Navigate a specific tab to *url* in place."""
|
||||
|
||||
def _reuse_target(self, url: str, *, reuse: bool, reuse_domain: bool, reuse_title: str | None):
|
||||
if not (reuse or reuse_domain or reuse_title):
|
||||
def reusable_tab(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
reuse: bool | str = False,
|
||||
reuse_domain: bool = False,
|
||||
reuse_title: str | None = None,
|
||||
) -> Tab | None:
|
||||
"""Return the open tab that already fits *url*, or ``None``.
|
||||
|
||||
``reuse`` takes ``True`` (nearly identical URL), ``'exact'``, ``'similar'``,
|
||||
``'origin'`` or ``'none'``; ``reuse_title`` falls back to a title substring.
|
||||
"""
|
||||
mode = normalize_mode(reuse, reuse_domain)
|
||||
if mode == "none" and not reuse_title:
|
||||
return None
|
||||
from urllib.parse import urlparse
|
||||
wanted = urlparse(url)
|
||||
wanted_host = wanted.netloc.lower()
|
||||
for tab in self._c.tabs.list():
|
||||
tab_url = tab.url or ""
|
||||
parsed = urlparse(tab_url)
|
||||
if reuse and tab_url == url:
|
||||
return tab
|
||||
if reuse_domain and wanted_host and parsed.netloc.lower() == wanted_host:
|
||||
return tab
|
||||
if reuse_title and reuse_title.lower() in (tab.title or "").lower():
|
||||
return tab
|
||||
tabs = self._c.tabs.list()
|
||||
match = find_reusable_tab(tabs, url, mode)
|
||||
if match is not None:
|
||||
return match
|
||||
if reuse_title:
|
||||
needle = reuse_title.lower()
|
||||
for tab in tabs:
|
||||
if needle in (tab.title or "").lower():
|
||||
return tab
|
||||
return None
|
||||
|
||||
def search(
|
||||
|
||||
+17
-1
@@ -5,6 +5,7 @@ from collections.abc import Callable, Iterable
|
||||
|
||||
from browser_cli.models import BrowserCounts, Tab
|
||||
from browser_cli.sdk.base import Namespace
|
||||
from browser_cli.url_match import needs_navigation
|
||||
|
||||
# Keep SDK-driven bulk closes comfortably below the native-host response
|
||||
# timeout. The extension can close larger batches, but real browsers may take
|
||||
@@ -32,14 +33,29 @@ class TabsNS(Namespace):
|
||||
focus: bool = False,
|
||||
window: str | None = None,
|
||||
group: str | None = None,
|
||||
reuse: bool | str = False,
|
||||
reuse_domain: bool = False,
|
||||
reuse_title: str | None = None,
|
||||
) -> Tab:
|
||||
"""Open *url* in a new tab and return a bound :class:`Tab`.
|
||||
|
||||
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
|
||||
Pass ``focus=True`` to explicitly bring the created tab/window forward.
|
||||
``reuse=True`` navigates an already-open tab with a nearly identical URL
|
||||
instead of creating a duplicate.
|
||||
"""
|
||||
if wait:
|
||||
return self._c.nav.open_wait(url, timeout=timeout, background=background, focus=focus, window=window, group=group)
|
||||
return self._c.nav.open_wait(
|
||||
url, timeout=timeout, background=background, focus=focus, window=window, group=group,
|
||||
reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title,
|
||||
)
|
||||
existing = self._c.nav.reusable_tab(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
|
||||
if existing is not None:
|
||||
if needs_navigation(existing.url, url):
|
||||
self._c.nav.to(existing.id, url)
|
||||
if focus:
|
||||
self.activate(existing.id)
|
||||
return self.status(existing.id)
|
||||
return self.require_tab(
|
||||
self.command("navigate.open", {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}),
|
||||
"navigate.open returned unexpected data",
|
||||
|
||||
Reference in New Issue
Block a user