feat: improve remote browser tree routing
Testing / remote-protocol-compat (0.9.3) (push) Successful in 43s
Testing / test (push) Successful in 1m1s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 39s
Build & Publish Package / publish (push) Successful in 58s
Package Extension / package-extension (push) Successful in 1m15s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 43s
Testing / test (push) Successful in 1m1s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 39s
Build & Publish Package / publish (push) Successful in 58s
Package Extension / package-extension (push) Successful in 1m15s
- Allow remote host aliases passed via --browser to fan out for read-only multi-browser SDK paths while preserving strict routing for mutating commands. - Add remote host grouping and scoped profile labels to tabs tree output so global views avoid repeated host prefixes. - Carry browser family metadata through remote targets, tabs, and groups and style tree browser labels by family. - Split CLI rendering helpers into a typed rendering package with dedicated common, label, tabs-tree, and windows-tree modules. - Bump browser-cli and extension versions to 0.15.5. - Cover the new routing and rendering behavior with unit and CLI tests.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
"""Reusable rendering helpers for CLI command modules."""
|
||||
from browser_cli.commands.rendering.common import (
|
||||
Column,
|
||||
item_value,
|
||||
print_table_rows,
|
||||
print_tree,
|
||||
shorten,
|
||||
terminal_width,
|
||||
tree_title_limit,
|
||||
tree_url_limit,
|
||||
)
|
||||
from browser_cli.commands.rendering.labels import (
|
||||
BROWSER_FAMILY_STYLES,
|
||||
DEFAULT_BROWSER_STYLE,
|
||||
DEFAULT_SCOPE,
|
||||
browser_label_style,
|
||||
group_tree_label,
|
||||
no_wrap_text,
|
||||
scoped_browser_label,
|
||||
tab_tree_label,
|
||||
)
|
||||
from browser_cli.commands.rendering.tabs_tree import (
|
||||
TabsTreeBuilder,
|
||||
browser_label_key,
|
||||
browser_scope,
|
||||
build_tabs_tree,
|
||||
tab_group_id,
|
||||
tab_sort_key,
|
||||
tab_window_id,
|
||||
)
|
||||
from browser_cli.commands.rendering.windows_tree import build_windows_tree
|
||||
|
||||
__all__ = [
|
||||
"BROWSER_FAMILY_STYLES",
|
||||
"Column",
|
||||
"DEFAULT_BROWSER_STYLE",
|
||||
"DEFAULT_SCOPE",
|
||||
"TabsTreeBuilder",
|
||||
"browser_label_key",
|
||||
"browser_label_style",
|
||||
"browser_scope",
|
||||
"build_tabs_tree",
|
||||
"build_windows_tree",
|
||||
"group_tree_label",
|
||||
"item_value",
|
||||
"no_wrap_text",
|
||||
"print_table_rows",
|
||||
"print_tree",
|
||||
"scoped_browser_label",
|
||||
"shorten",
|
||||
"tab_group_id",
|
||||
"tab_sort_key",
|
||||
"tab_tree_label",
|
||||
"tab_window_id",
|
||||
"terminal_width",
|
||||
"tree_title_limit",
|
||||
"tree_url_limit",
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Common Rich rendering helpers for CLI command modules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from typing import TypeVar, cast
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree
|
||||
|
||||
Row = object
|
||||
CellValue = object
|
||||
Column = tuple[str, Callable[[Row], CellValue]]
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def item_value(item: Row, name: str, default: T | None = None) -> CellValue | T | None:
|
||||
"""Read *name* from a dict-like or attribute object."""
|
||||
if isinstance(item, Mapping):
|
||||
return cast(Mapping[str, CellValue], item).get(name, default)
|
||||
return getattr(item, name, default)
|
||||
|
||||
def text_value(value: CellValue | None, default: str = "") -> str:
|
||||
"""Coerce a nullable cell value to display text."""
|
||||
return default if value is None else str(value)
|
||||
|
||||
def int_value(value: CellValue | None, default: int = 0) -> int:
|
||||
"""Coerce a cell value to int, falling back when conversion is not possible."""
|
||||
try:
|
||||
return int(cast(int | str | float | bool, value))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def shorten(value: str | None, limit: int) -> str:
|
||||
"""Return *value* shortened to *limit* cells-ish, using an ellipsis."""
|
||||
value = value or ""
|
||||
return value if len(value) <= limit else value[:max(0, limit - 1)] + "…"
|
||||
|
||||
def terminal_width(console: Console | None = None, *, fallback: int = 120) -> int:
|
||||
"""Best-effort terminal width for interactive and redirected output.
|
||||
|
||||
Rich falls back to 80 columns when stdout is redirected. browser-cli output is
|
||||
often piped into files for inspection, so also consult ``shutil``/``COLUMNS``
|
||||
and prefer the wider value.
|
||||
"""
|
||||
rich_width = (console.width if console is not None else 0) or 0
|
||||
shell_width = shutil.get_terminal_size((fallback, 20)).columns
|
||||
return max(rich_width, shell_width)
|
||||
|
||||
def tree_title_limit(*, console: Console | None = None, show_browser: bool = False, show_urls: bool = False) -> int:
|
||||
"""Title width for tree labels, reserving space for branches/IDs/metadata."""
|
||||
reserve = 48 if show_urls else 32
|
||||
if show_browser:
|
||||
reserve += 4
|
||||
return max(50, terminal_width(console) - reserve)
|
||||
|
||||
def tree_url_limit(title_limit: int, *, console: Console | None = None) -> int:
|
||||
"""URL width for tree labels when URLs are displayed."""
|
||||
return max(35, terminal_width(console) - title_limit - 40)
|
||||
|
||||
def print_tree(tree: Tree, *, console: Console | None = None) -> None:
|
||||
"""Render a Rich tree using the detected full terminal width."""
|
||||
Console(width=terminal_width(console)).print(tree)
|
||||
|
||||
def print_table_rows(
|
||||
rows: Sequence[Row],
|
||||
columns: Sequence[Column],
|
||||
*,
|
||||
console: Console,
|
||||
empty_message: str,
|
||||
show_header: bool = True,
|
||||
header_style: str = "bold cyan",
|
||||
) -> None:
|
||||
"""Render a small Rich table from arbitrary row objects."""
|
||||
if not rows:
|
||||
console.print(empty_message)
|
||||
return
|
||||
table = Table(show_header=show_header, header_style=header_style)
|
||||
for header, _getter in columns:
|
||||
table.add_column(header)
|
||||
for row in rows:
|
||||
table.add_row(*[text_value(getter(row)) for _header, getter in columns])
|
||||
Console(width=terminal_width(console)).print(table)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Rich label helpers for tab/window tree renderers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from browser_cli.commands.rendering.common import Row, int_value, item_value, shorten, text_value
|
||||
|
||||
BROWSER_FAMILY_STYLES = {
|
||||
"firefox": "orange1",
|
||||
"chrome": "cyan",
|
||||
"chromium": "cyan",
|
||||
"brave": "cyan",
|
||||
"edge": "cyan",
|
||||
"vivaldi": "cyan",
|
||||
}
|
||||
DEFAULT_SCOPE = "local"
|
||||
DEFAULT_BROWSER_STYLE = "bold cyan"
|
||||
|
||||
def no_wrap_text() -> Text:
|
||||
"""Text configured for one-line tree labels with edge ellipsis."""
|
||||
return Text(no_wrap=True, overflow="ellipsis")
|
||||
|
||||
def tab_tree_label(tab: Row, *, title_limit: int, show_urls: bool = False, url_limit: int = 55) -> Text:
|
||||
"""Reusable one-line label for a browser tab in tree views."""
|
||||
label = no_wrap_text()
|
||||
label.append(f"[{text_value(item_value(tab, 'id'))}] ", style="dim")
|
||||
label.append(shorten(text_value(item_value(tab, 'title'), "(untitled)") or "(untitled)", title_limit))
|
||||
if bool(item_value(tab, "active", False)):
|
||||
label.append(" *", style="green")
|
||||
url = text_value(item_value(tab, "url"))
|
||||
if show_urls and url:
|
||||
label.append(" — ", style="dim")
|
||||
label.append(shorten(url, url_limit), style="dim")
|
||||
return label
|
||||
|
||||
def group_tree_label(group_id: object, group: Row | None, *, title_limit: int) -> Text:
|
||||
"""Reusable one-line label for a browser tab group in tree views."""
|
||||
title = text_value(item_value(group, "title", "") if group is not None else "") or f"Group {group_id}"
|
||||
color = text_value(item_value(group, "color", "") if group is not None else "") or "group"
|
||||
count = int_value(item_value(group, "tab_count", item_value(group, "tabCount", 0)) if group is not None else 0)
|
||||
collapsed = bool(item_value(group, "collapsed", False)) if group is not None else False
|
||||
label = no_wrap_text()
|
||||
label.append(shorten(title, title_limit), style="bold")
|
||||
meta = [color]
|
||||
if count:
|
||||
meta.append(f"{count} tab" + ("" if count == 1 else "s"))
|
||||
if collapsed:
|
||||
meta.append("collapsed")
|
||||
label.append(" (" + ", ".join(meta) + ")", style="dim")
|
||||
return label
|
||||
|
||||
def browser_label_style(browser_name: str | None) -> str:
|
||||
"""Return a Rich style for a browser family label."""
|
||||
name = (browser_name or "").lower()
|
||||
for family, style in BROWSER_FAMILY_STYLES.items():
|
||||
if family in name:
|
||||
return style
|
||||
return DEFAULT_BROWSER_STYLE
|
||||
|
||||
def scoped_browser_label(browser: str, scope: str, *, grouped: bool) -> str:
|
||||
"""Shorten browser labels under a remote/local group node."""
|
||||
prefix = f"{scope}:"
|
||||
return browser[len(prefix):] if grouped and browser.startswith(prefix) else browser
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Tabs tree renderer."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
from rich.tree import Tree
|
||||
|
||||
from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit
|
||||
from browser_cli.commands.rendering.labels import (
|
||||
DEFAULT_BROWSER_STYLE,
|
||||
DEFAULT_SCOPE,
|
||||
browser_label_style,
|
||||
group_tree_label,
|
||||
scoped_browser_label,
|
||||
tab_tree_label,
|
||||
)
|
||||
|
||||
GroupId = object
|
||||
GroupKey = tuple[str, str, int | None, GroupId]
|
||||
TreeNodeKey = tuple[str, str]
|
||||
WindowNodeKey = tuple[str, str, int]
|
||||
BrowserGroupNodeKey = tuple[str, str, int, GroupId]
|
||||
|
||||
def browser_scope(item: Row) -> str:
|
||||
"""Return the remote/local scope key used by tree renderers."""
|
||||
return text_value(item_value(item, "browser_group")) or DEFAULT_SCOPE
|
||||
|
||||
def browser_label_key(item: Row) -> str:
|
||||
"""Return the browser/profile key used by tree renderers."""
|
||||
return text_value(item_value(item, "browser")) or DEFAULT_SCOPE
|
||||
|
||||
def tab_window_id(tab: Row) -> int:
|
||||
"""Return a stable window id from object or dict-shaped tab responses."""
|
||||
return int_value(item_value(tab, "window_id", item_value(tab, "windowId", 0)))
|
||||
|
||||
def tab_group_id(tab: Row) -> GroupId | None:
|
||||
"""Return a tab group id from object or dict-shaped tab responses."""
|
||||
group_id = item_value(tab, "group_id", item_value(tab, "groupId"))
|
||||
return None if group_id is None else group_id
|
||||
|
||||
def tab_sort_key(tab: Row) -> tuple[str, str, int, int, int, int]:
|
||||
"""Stable tab ordering across multi-browser responses."""
|
||||
group_id = tab_group_id(tab)
|
||||
return (
|
||||
browser_scope(tab),
|
||||
browser_label_key(tab),
|
||||
tab_window_id(tab),
|
||||
int_value(item_value(tab, "index", 0)),
|
||||
int_value(group_id, -1) if group_id is not None else -1,
|
||||
int_value(item_value(tab, "id", 0)),
|
||||
)
|
||||
|
||||
class TabsTreeBuilder:
|
||||
"""Stateful builder for the browser tabs tree.
|
||||
|
||||
The tree has optional scope nodes (remote host/local), then browser/profile,
|
||||
then window, then browser tab-groups/tabs. Keeping this state in a helper
|
||||
keeps ``build_tabs_tree`` small while preserving stable node reuse.
|
||||
"""
|
||||
|
||||
tabs: list[Row]
|
||||
groups: list[Row]
|
||||
show_urls: bool
|
||||
show_browser: bool
|
||||
group_by_scope: bool
|
||||
title_limit: int
|
||||
url_limit: int
|
||||
root: Tree
|
||||
group_info: dict[GroupKey, Row]
|
||||
browser_styles: dict[str, str]
|
||||
scope_nodes: dict[str, Tree]
|
||||
browser_nodes: dict[TreeNodeKey, Tree]
|
||||
window_nodes: dict[WindowNodeKey, Tree]
|
||||
group_nodes: dict[BrowserGroupNodeKey, Tree]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tabs: Iterable[Row],
|
||||
groups: Iterable[Row],
|
||||
*,
|
||||
console: Console,
|
||||
show_urls: bool = False,
|
||||
):
|
||||
self.tabs = sorted(tabs, key=tab_sort_key)
|
||||
self.groups = list(groups)
|
||||
self.show_urls = show_urls
|
||||
self.show_browser = any(bool(item_value(tab, "browser")) for tab in self.tabs)
|
||||
self.group_by_scope = any(bool(item_value(item, "browser_group")) for item in self.tabs + self.groups)
|
||||
self.title_limit = tree_title_limit(console=console, show_browser=self.show_browser, show_urls=show_urls)
|
||||
self.url_limit = tree_url_limit(self.title_limit, console=console)
|
||||
self.root = Tree("[bold]Tabs[/bold]")
|
||||
self.group_info = self._group_info()
|
||||
self.browser_styles = self._browser_styles()
|
||||
self.scope_nodes = {}
|
||||
self.browser_nodes = {}
|
||||
self.window_nodes = {}
|
||||
self.group_nodes = {}
|
||||
|
||||
def build(self) -> Tree:
|
||||
for tab in self.tabs:
|
||||
self._add_tab(tab)
|
||||
return self.root
|
||||
|
||||
def _group_info(self) -> dict[GroupKey, Row]:
|
||||
return {
|
||||
(
|
||||
browser_scope(group),
|
||||
browser_label_key(group),
|
||||
int_value(item_value(group, "window_id", item_value(group, "windowId")), 0),
|
||||
item_value(group, "id"),
|
||||
): group
|
||||
for group in self.groups
|
||||
}
|
||||
|
||||
def _browser_styles(self) -> dict[str, str]:
|
||||
styles: dict[str, str] = {}
|
||||
for item in self.tabs + self.groups:
|
||||
key = browser_label_key(item)
|
||||
styles.setdefault(key, browser_label_style(text_value(item_value(item, "browser_name")) or None))
|
||||
return styles
|
||||
|
||||
def _scope_node(self, scope: str) -> Tree:
|
||||
if not self.group_by_scope:
|
||||
return self.root
|
||||
node = self.scope_nodes.get(scope)
|
||||
if node is None:
|
||||
node = self.root.add(Text(scope, style="bold"))
|
||||
self.scope_nodes[scope] = node
|
||||
return node
|
||||
|
||||
def _browser_node(self, scope: str, browser: str) -> Tree:
|
||||
key = (scope, browser)
|
||||
node = self.browser_nodes.get(key)
|
||||
if node is None:
|
||||
parent = self._scope_node(scope)
|
||||
if self.show_browser:
|
||||
label = scoped_browser_label(browser, scope, grouped=self.group_by_scope)
|
||||
node = parent.add(Text(label, style=self.browser_styles.get(browser, DEFAULT_BROWSER_STYLE)))
|
||||
else:
|
||||
node = parent
|
||||
self.browser_nodes[key] = node
|
||||
return node
|
||||
|
||||
def _window_node(self, scope: str, browser: str, window_id: int) -> Tree:
|
||||
key = (scope, browser, window_id)
|
||||
node = self.window_nodes.get(key)
|
||||
if node is None:
|
||||
node = self._browser_node(scope, browser).add(f"Window {window_id}")
|
||||
self.window_nodes[key] = node
|
||||
return node
|
||||
|
||||
def _group_node(self, scope: str, browser: str, window_id: int, group_id: GroupId, parent: Tree) -> Tree:
|
||||
key = (scope, browser, window_id, group_id)
|
||||
node = self.group_nodes.get(key)
|
||||
if node is None:
|
||||
group = self.group_info.get(key) or self.group_info.get((scope, browser, None, group_id))
|
||||
node = parent.add(group_tree_label(group_id, group, title_limit=self.title_limit))
|
||||
self.group_nodes[key] = node
|
||||
return node
|
||||
|
||||
def _add_tab(self, tab: Row) -> None:
|
||||
scope = browser_scope(tab)
|
||||
browser = browser_label_key(tab)
|
||||
window_id = tab_window_id(tab)
|
||||
window_node = self._window_node(scope, browser, window_id)
|
||||
group_id = tab_group_id(tab)
|
||||
parent = window_node if group_id is None else self._group_node(scope, browser, window_id, group_id, window_node)
|
||||
parent.add(tab_tree_label(
|
||||
tab,
|
||||
title_limit=self.title_limit,
|
||||
show_urls=self.show_urls,
|
||||
url_limit=self.url_limit,
|
||||
))
|
||||
|
||||
def build_tabs_tree(
|
||||
tabs: Iterable[Row],
|
||||
groups: Iterable[Row],
|
||||
*,
|
||||
console: Console,
|
||||
show_urls: bool = False,
|
||||
) -> Tree:
|
||||
"""Build a remote/local → browser → window → group/tab tree from tab responses."""
|
||||
return TabsTreeBuilder(tabs, groups, console=console, show_urls=show_urls).build()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Windows tree renderer."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from rich.console import Console
|
||||
from rich.tree import Tree
|
||||
|
||||
from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit
|
||||
from browser_cli.commands.rendering.labels import tab_tree_label
|
||||
|
||||
WindowRow = Mapping[str, object]
|
||||
|
||||
def build_windows_tree(windows: Iterable[WindowRow], tabs: Iterable[Row], *, console: Console) -> Tree:
|
||||
"""Build a window → tab tree from window and tab responses."""
|
||||
windows = list(windows)
|
||||
tabs = list(tabs)
|
||||
title_limit = tree_title_limit(console=console, show_browser=any("browser" in w for w in windows), show_urls=True)
|
||||
url_limit = tree_url_limit(title_limit, console=console)
|
||||
root = Tree("[bold]Windows[/bold]")
|
||||
for window in sorted(windows, key=lambda item: (text_value(item.get("browser")), int_value(item.get("id")))):
|
||||
window_id = int_value(window.get("id"))
|
||||
label = f"Window {window_id}"
|
||||
alias = text_value(window.get("alias"))
|
||||
browser = text_value(window.get("browser"))
|
||||
if alias:
|
||||
label += f" ({alias})"
|
||||
if browser:
|
||||
label = f"{browser}: " + label
|
||||
node = root.add(label)
|
||||
window_tabs = [
|
||||
tab for tab in tabs
|
||||
if int_value(item_value(tab, "window_id", item_value(tab, "windowId"))) == window_id
|
||||
and (not browser or text_value(item_value(tab, "browser")) == browser)
|
||||
]
|
||||
for tab in sorted(window_tabs, key=lambda item: int_value(item_value(item, "index", 0))):
|
||||
node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit))
|
||||
return root
|
||||
Reference in New Issue
Block a user