diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py index f0b3644..00d9607 100644 --- a/browser_cli/__init__.py +++ b/browser_cli/__init__.py @@ -117,6 +117,10 @@ class BrowserCLI: def focus_url(self, pattern: str) -> None: self._cmd("navigate.focus", {"pattern": pattern}) + def navigate_tab(self, tab_id: int, url: str) -> None: + """Navigate a specific tab to *url*.""" + self._cmd("navigate.to", {"tabId": tab_id, "url": url}) + # ── Search ──────────────────────────────────────────────────────────── def search( @@ -168,6 +172,13 @@ class BrowserCLI: """Switch browser focus to a tab by ID.""" self._cmd("tabs.active", {"tabId": tab_id}) + def window_active_tab(self, window_id: int) -> Tab: + """Return active tab for a specific browser window.""" + data = self._cmd("tabs.active_in_window", {"windowId": window_id}) + if not isinstance(data, dict) or "id" not in data: + raise RuntimeError(f"No active tab found for window {window_id}") + return self._make_tab(data) + def tabs_filter(self, pattern_or_filter: str | Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]]) -> list[Tab]: """Return tabs filtered by pattern or a Python callable.""" if isinstance(pattern_or_filter, str): diff --git a/browser_cli/models.py b/browser_cli/models.py index 8064a5c..0f18224 100644 --- a/browser_cli/models.py +++ b/browser_cli/models.py @@ -87,11 +87,8 @@ class Tab: return self._b()._cmd("tabs.html", {"tabId": self.id}) def open(self, url: str, *, background: bool = False) -> None: - """Navigate this tab to *url*.""" - # Re-uses navigate.open which opens a new tab; for in-place navigation - # we target by tabId via the focus then navigate approach. For now we - # open a new tab in the same window as a convenience. - self._b()._cmd("navigate.open", {"url": url, "background": background}) + """Navigate this tab to *url* in place.""" + self._b().navigate_tab(self.id, url) # ── Group ───────────────────────────────────────────────────────────────────── diff --git a/extension/background.js b/extension/background.js index 1f440d2..ce6f4b3 100644 --- a/extension/background.js +++ b/extension/background.js @@ -121,6 +121,7 @@ async function dispatch(command, args) { switch (command) { // ── Navigation ──────────────────────────────────────────────────────── case "navigate.open": return navOpen(args); + case "navigate.to": return navTo(args); case "navigate.reload": return navReload(args, false); case "navigate.hard_reload": return navReload(args, true); case "navigate.back": return navBack(args); @@ -132,6 +133,7 @@ async function dispatch(command, args) { case "tabs.close": return tabsClose(args); case "tabs.move": return tabsMove(args); case "tabs.active": return tabsActive(args); + case "tabs.active_in_window": return tabsActiveInWindow(args); case "tabs.filter": return tabsFilter(args); case "tabs.count": return tabsCount(args); case "tabs.query": return tabsQuery(args); @@ -191,9 +193,11 @@ async function dispatch(command, args) { // ── Navigation ──────────────────────────────────────────────────────────────── -async function navOpen({ url, background, window: windowName, group: groupNameOrId }) { +async function navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }) { let windowId; - if (windowName) { + if (explicitWindowId != null) { + windowId = explicitWindowId; + } else if (windowName) { const aliases = await getAliases(); const entry = Object.entries(aliases).find(([, v]) => v === windowName); if (entry) windowId = parseInt(entry[0]); @@ -221,6 +225,11 @@ async function navOpen({ url, background, window: windowName, group: groupNameOr return { id: tab.id, url: tab.url }; } +async function navTo({ tabId, url }) { + const tab = await chrome.tabs.update(tabId, { url }); + return { id: tab.id, url: tab.url || url }; +} + async function navReload({ tabId }, bypassCache) { const tab = tabId ? { id: tabId } : await getActiveTab(); await chrome.tabs.reload(tab.id, { bypassCache }); @@ -326,6 +335,15 @@ async function tabsActive({ tabId }) { return { tabId }; } +async function tabsActiveInWindow({ windowId }) { + const activeTabs = await chrome.tabs.query({ windowId, active: true }); + const tab = activeTabs[0]; + if (!tab) { + throw new Error(`No active tab found for window ${windowId}`); + } + return tabInfo(tab); +} + async function tabsFilter({ pattern }) { const all = await chrome.tabs.query({}); return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo); diff --git a/extension/manifest.json b/extension/manifest.json index 0d6def5..18dce74 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.5.7", + "version": "0.5.8", "description": "Control your browser from the terminal via browser-cli", "permissions": [ "tabs", diff --git a/pyproject.toml b/pyproject.toml index d7d37bd..54867c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.5.7" +version = "0.5.8" description = "Control your real running browser from the terminal via a Chrome extension" requires-python = ">=3.10" dependencies = [ diff --git a/tests/test_api.py b/tests/test_api.py index 202b34f..d2d05dd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -154,6 +154,12 @@ class TestNavigation: b.focus_url("github.com") mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None) + def test_navigate_tab(self, b, mock_send): + b.navigate_tab(5, "https://example.com") + mock_send.assert_called_once_with( + "navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None + ) + def test_profile_forwarded(self, b_profile, mock_send): b_profile.reload() mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave") @@ -244,6 +250,18 @@ class TestTabs: b.tabs_active(10) mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None) + def test_window_active_tab(self, b, mock_send): + mock_send.return_value = TAB_DATA + tab = b.window_active_tab(1) + assert isinstance(tab, Tab) + assert tab.id == 10 + mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None) + + def test_window_active_tab_missing_raises(self, b, mock_send): + mock_send.return_value = None + with pytest.raises(RuntimeError, match="No active tab found for window 1"): + b.window_active_tab(1) + def test_tabs_filter(self, b, mock_send): mock_send.return_value = [TAB_DATA] tabs = b.tabs_filter("example") @@ -564,7 +582,15 @@ class TestTabModel: def test_open(self, tab, mock_send): tab.open("https://new.example.com") mock_send.assert_called_once_with( - "navigate.open", {"url": "https://new.example.com", "background": False}, profile=None + "navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None + ) + + def test_open_background_changes_same_tab(self, tab, mock_send): + tab.open("https://new.example.com", background=True) + mock_send.assert_called_once_with( + "navigate.to", + {"tabId": 10, "url": "https://new.example.com"}, + profile=None, ) def test_unbound_raises(self): diff --git a/tests/test_nav.py b/tests/test_nav.py index db37caa..20d2728 100644 --- a/tests/test_nav.py +++ b/tests/test_nav.py @@ -81,3 +81,24 @@ def test_nav_open_in_background(browser): assert not new_tab.get("active"), "background tab should not be active" finally: browser("tabs.close", {"tabId": new_id}) + + +def test_nav_to_updates_existing_tab(browser): + result = browser("navigate.open", {"url": "https://example.com", "background": True}) + tab_id = result["id"] + + try: + before_ids = {t["id"] for t in browser("tabs.list")} + updated = browser("navigate.to", {"tabId": tab_id, "url": "https://example.org"}) + assert updated["id"] == tab_id + + tabs = browser("tabs.list") + after_ids = {t["id"] for t in tabs} + assert after_ids == before_ids + tab = next(t for t in tabs if t["id"] == tab_id) + assert "example.org" in (tab.get("url") or "") + finally: + try: + browser("tabs.close", {"tabId": tab_id}) + except Exception: + pass diff --git a/tests/test_tabs.py b/tests/test_tabs.py index 9264c27..669b82c 100644 --- a/tests/test_tabs.py +++ b/tests/test_tabs.py @@ -44,6 +44,13 @@ def test_tabs_active_exists(browser): assert len(active) >= 1, "Expected at least one active tab" +def test_tabs_active_in_window(browser): + active = next(t for t in browser("tabs.list") if t.get("active")) + result = browser("tabs.active_in_window", {"windowId": active["windowId"]}) + assert result["id"] == active["id"] + assert result["windowId"] == active["windowId"] + + def test_tabs_html(browser, http_tab): html = browser("tabs.html", {"tabId": http_tab["id"]}) assert isinstance(html, str)