- 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
137 lines
5.0 KiB
Python
137 lines
5.0 KiB
Python
"""Navigation namespace: ``b.nav.*``."""
|
|
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}
|
|
|
|
def _tab_args(self, tab_id=None):
|
|
return {"tabId": tab_id}
|
|
|
|
class NavigationNS(Namespace):
|
|
"""Open URLs, navigate history, and focus tabs."""
|
|
|
|
def open(
|
|
self,
|
|
url: str,
|
|
*,
|
|
background: bool = False,
|
|
focus: bool = False,
|
|
window: str | None = None,
|
|
group: str | None = None,
|
|
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. ``reuse=True`` matches nearly identical URLs;
|
|
pass ``reuse='exact'`` or ``reuse='origin'`` to tighten or widen that.
|
|
"""
|
|
tab = self.reusable_tab(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
|
|
if tab is not None:
|
|
if needs_navigation(tab.url, url):
|
|
self.to(tab.id, url)
|
|
if focus:
|
|
self._c.tabs.activate(tab.id)
|
|
return None
|
|
self.command("navigate.open", _open_args(self, url, background=background, focus=focus, window=window, group=group))
|
|
return None
|
|
|
|
def open_wait(
|
|
self,
|
|
url: str,
|
|
*,
|
|
timeout: float = 30.0,
|
|
background: bool = False,
|
|
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 block until fully loaded. Returns the Tab."""
|
|
tab = self.reusable_tab(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
|
|
if tab is not None:
|
|
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)
|
|
return self.require_tab(
|
|
self.command("navigate.open_wait", {
|
|
"url": url, "timeout": int(timeout * 1000),
|
|
"background": background or not focus, "focus": focus, "window": window, "group": group,
|
|
}),
|
|
"navigate.open_wait returned unexpected data",
|
|
)
|
|
|
|
@sdk_command("navigate.reload", _tab_args)
|
|
def reload(self, tab_id: int | None = None) -> None:
|
|
"""Reload the active tab or a specific tab."""
|
|
|
|
@sdk_command("navigate.hard_reload", _tab_args)
|
|
def hard_reload(self, tab_id: int | None = None) -> None:
|
|
"""Hard-reload the active tab or a specific tab."""
|
|
|
|
@sdk_command("navigate.back", _tab_args)
|
|
def back(self, tab_id: int | None = None) -> None:
|
|
"""Navigate back in the active tab or a specific tab."""
|
|
|
|
@sdk_command("navigate.forward", _tab_args)
|
|
def forward(self, tab_id: int | None = None) -> None:
|
|
"""Navigate forward in the active tab or a specific tab."""
|
|
|
|
@sdk_command("navigate.focus", lambda self, pattern: {"pattern": pattern})
|
|
def focus(self, pattern: str) -> dict | None:
|
|
"""Focus the first tab whose URL matches *pattern*. Returns the matched tab info, if any."""
|
|
|
|
@sdk_command("navigate.to", lambda self, tab_id, url: {"tabId": tab_id, "url": url})
|
|
def to(self, tab_id: int, url: str) -> None:
|
|
"""Navigate a specific tab to *url* in place."""
|
|
|
|
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
|
|
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(
|
|
self, engine: str, query: str, *,
|
|
background: bool = False, focus: bool = False, window: str | None = None, group: str | None = None,
|
|
) -> None:
|
|
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
|
|
from urllib.parse import quote_plus
|
|
from browser_cli.search.engines import ENGINES
|
|
template = ENGINES.get(engine)
|
|
if template is None:
|
|
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
|
|
url = template.format(query=quote_plus(query))
|
|
self.command("navigate.open", {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group})
|