- 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
139 lines
4.7 KiB
Python
139 lines
4.7 KiB
Python
"""Match a requested URL against already-open tabs.
|
|
|
|
Agents and scripts usually want the tab that already shows a page instead of a
|
|
duplicate of it. These helpers answer "is this tab already (nearly) the URL I
|
|
am about to open?" without touching the browser.
|
|
|
|
Modes:
|
|
``none`` never reuse a tab
|
|
``exact`` only a byte-identical URL
|
|
``similar`` same site and page, ignoring fragment, trailing slash, query
|
|
order, ``www.`` and default ports; a request for the site root
|
|
also matches any tab on that site
|
|
``origin`` any tab on the same site, whatever its path
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any, Literal
|
|
from urllib.parse import parse_qsl, urlsplit
|
|
|
|
ReuseMode = Literal["none", "exact", "similar", "origin"]
|
|
|
|
REUSE_MODES: tuple[str, ...] = ("none", "exact", "similar", "origin")
|
|
|
|
_DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
|
|
_ROOT_PATHS = {"", "/"}
|
|
|
|
@dataclass(frozen=True)
|
|
class UrlKey:
|
|
"""Canonical, comparable form of a URL."""
|
|
|
|
scheme: str
|
|
host: str
|
|
port: int | None
|
|
path: str
|
|
query: tuple[tuple[str, str], ...]
|
|
|
|
@property
|
|
def origin(self) -> tuple[str, str, int | None]:
|
|
return (self.scheme, self.host, self.port)
|
|
|
|
@property
|
|
def is_root(self) -> bool:
|
|
return self.path == "" and not self.query
|
|
|
|
def url_key(url: str | None) -> UrlKey | None:
|
|
"""Return the canonical key of *url*, or ``None`` when it is not comparable."""
|
|
if not url:
|
|
return None
|
|
parts = urlsplit(url.strip())
|
|
if not parts.scheme or not parts.hostname:
|
|
return None
|
|
scheme = parts.scheme.lower()
|
|
host = parts.hostname.lower()
|
|
if host.startswith("www.") and len(host) > 4:
|
|
host = host[4:]
|
|
port = parts.port
|
|
if port is not None and _DEFAULT_PORTS.get(scheme) == port:
|
|
port = None
|
|
path = parts.path.rstrip("/")
|
|
query = tuple(sorted(parse_qsl(parts.query, keep_blank_values=True)))
|
|
return UrlKey(scheme=scheme, host=host, port=port, path=path, query=query)
|
|
|
|
def normalize_mode(reuse: bool | str | None, reuse_domain: bool = False) -> ReuseMode:
|
|
"""Fold the boolean flags and explicit mode names into one :data:`ReuseMode`."""
|
|
if reuse_domain:
|
|
return "origin"
|
|
if reuse is True:
|
|
return "similar"
|
|
if reuse is False or reuse is None:
|
|
return "none"
|
|
mode = str(reuse).lower()
|
|
if mode not in REUSE_MODES:
|
|
raise ValueError(f"Unknown reuse mode '{reuse}'. Use one of: {', '.join(REUSE_MODES)}")
|
|
return mode # type: ignore[return-value]
|
|
|
|
def urls_match(url: str, candidate: str | None, mode: ReuseMode) -> bool:
|
|
"""Report whether *candidate* may be reused for *url* under *mode*."""
|
|
if mode == "none" or not candidate:
|
|
return False
|
|
if mode == "exact":
|
|
return candidate == url
|
|
wanted, other = url_key(url), url_key(candidate)
|
|
if wanted is None or other is None:
|
|
return False
|
|
if wanted.origin != other.origin:
|
|
return False
|
|
if mode == "origin":
|
|
return True
|
|
return wanted.path == other.path or wanted.is_root
|
|
|
|
def needs_navigation(current: str | None, url: str) -> bool:
|
|
"""Report whether a reused tab still has to be navigated to *url*."""
|
|
if current == url:
|
|
return False
|
|
wanted, other = url_key(url), url_key(current)
|
|
return wanted is None or other is None or wanted != other
|
|
|
|
def tab_url(tab: Any) -> str | None:
|
|
"""Read a tab URL from an SDK ``Tab`` or a serialized tab dict."""
|
|
if isinstance(tab, dict):
|
|
value = tab.get("url")
|
|
else:
|
|
value = getattr(tab, "url", None)
|
|
return value if isinstance(value, str) else None
|
|
|
|
def tab_id(tab: Any) -> int | None:
|
|
"""Read a tab ID from an SDK ``Tab`` or a serialized tab dict."""
|
|
value = tab.get("id") if isinstance(tab, dict) else getattr(tab, "id", None)
|
|
return value if isinstance(value, int) else None
|
|
|
|
def find_reusable_tab(tabs: Any, url: str, mode: ReuseMode):
|
|
"""Return the open tab that best fits *url*, or ``None``.
|
|
|
|
Closer matches win: an identical URL beats a canonical match, which beats a
|
|
looser site-level match; among equally loose matches the tab closest to the
|
|
requested page wins.
|
|
"""
|
|
if mode == "none":
|
|
return None
|
|
wanted = url_key(url)
|
|
best, best_rank = None, None
|
|
for tab in tabs or ():
|
|
candidate = tab_url(tab)
|
|
if not urls_match(url, candidate, mode):
|
|
continue
|
|
rank = _rank(wanted, candidate)
|
|
if best_rank is None or rank < best_rank:
|
|
best, best_rank = tab, rank
|
|
return best
|
|
|
|
def _rank(wanted: UrlKey | None, candidate: str | None) -> tuple[int, int]:
|
|
"""Lower is a closer match: exact string, canonical, then shallowest path."""
|
|
other = url_key(candidate)
|
|
if wanted is not None and other is not None and wanted == other:
|
|
return (0, 0)
|
|
depth = other.path.count("/") if other is not None else 99
|
|
return (1, depth)
|