diff --git a/browser_cli/commands/rendering.py b/browser_cli/commands/rendering.py new file mode 100644 index 0000000..731ef03 --- /dev/null +++ b/browser_cli/commands/rendering.py @@ -0,0 +1,55 @@ +"""Reusable rendering helpers for CLI command modules.""" +from __future__ import annotations + +import shutil + +from rich.console import Console +from rich.text import Text +from rich.tree import Tree + +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 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, *, 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"[{tab.id}] ", style="dim") + label.append(shorten(tab.title or "(untitled)", title_limit)) + if tab.active: + label.append(" *", style="green") + if show_urls and tab.url: + label.append(" — ", style="dim") + label.append(shorten(tab.url, url_limit), style="dim") + return label + +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) diff --git a/browser_cli/commands/tabs.py b/browser_cli/commands/tabs.py index 455d707..761a3b8 100644 --- a/browser_cli/commands/tabs.py +++ b/browser_cli/commands/tabs.py @@ -2,78 +2,117 @@ import base64 import binascii import click from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option +from browser_cli.commands.rendering import no_wrap_text, print_tree, shorten, tab_tree_label, tree_title_limit, tree_url_limit from rich.console import Console from rich.table import Table +from rich.text import Text from rich.tree import Tree console = Console() +def _group_tree_label(group_id: int, group, *, title_limit: int) -> Text: + title = getattr(group, "title", "") or f"Group {group_id}" + color = getattr(group, "color", "") or "group" + count = getattr(group, "tab_count", 0) or 0 + collapsed = bool(getattr(group, "collapsed", 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 _tab_sort_key(tab): + return ( + tab.browser or "", + tab.window_id, + getattr(tab, "index", 0), + tab.group_id if tab.group_id is not None else -1, + tab.id, + ) + def _print_tabs(tabs, *, show_browser: bool = False) -> None: - if not tabs: - console.print("[yellow]No tabs found[/yellow]") - return - table = Table(show_header=True, header_style="bold cyan") - if show_browser: - table.add_column("Browser", no_wrap=True) - table.add_column("ID", style="dim", no_wrap=True) - table.add_column("Window", no_wrap=True) - table.add_column("Active", width=7) - table.add_column("Muted", width=7) - table.add_column("Title") - table.add_column("URL") - for t in tabs: - active = "[green]✓[/green]" if t.active else "" - muted = "[yellow]✓[/yellow]" if t.muted else "" - row = [ - (t.browser or "") if show_browser else None, - str(t.id), - str(t.window_id), - active, - muted, - (t.title or "")[:60], - (t.url or "")[:80], - ] - table.add_row(*[value for value in row if value is not None]) - console.print(table) + if not tabs: + console.print("[yellow]No tabs found[/yellow]") + return + table = Table(show_header=True, header_style="bold cyan") + if show_browser: + table.add_column("Browser", no_wrap=True) + table.add_column("ID", style="dim", no_wrap=True) + table.add_column("Window", no_wrap=True) + table.add_column("Active", width=7) + table.add_column("Muted", width=7) + table.add_column("Title") + table.add_column("URL") + for t in tabs: + active = "[green]✓[/green]" if t.active else "" + muted = "[yellow]✓[/yellow]" if t.muted else "" + row = [ + (t.browser or "") if show_browser else None, + str(t.id), + str(t.window_id), + active, + muted, + (t.title or "")[:60], + (t.url or "")[:80], + ] + table.add_row(*[value for value in row if value is not None]) + console.print(table) @click.group("tabs") def tabs_group(): - """Manage browser tabs.""" + """Manage browser tabs.""" @tabs_group.command("list") @handle_errors def tabs_list(): - """List all open tabs across all windows.""" - tabs = client_from_ctx().tabs.list() - _print_tabs(tabs, show_browser=any(t.browser for t in tabs)) + """List all open tabs across all windows.""" + tabs = client_from_ctx().tabs.list() + _print_tabs(tabs, show_browser=any(t.browser for t in tabs)) @tabs_group.command("tree") +@click.option("--urls", "show_urls", is_flag=True, help="Show shortened URLs next to tab titles") @handle_errors -def tabs_tree(): - """Show tabs grouped as a window/group tree.""" - tabs = sorted(client_from_ctx().tabs.list(), key=lambda t: ((t.browser or ""), t.window_id, t.group_id if t.group_id is not None else -1, t.index)) - root = Tree("[bold]Tabs[/bold]") - browsers = {} - windows = {} - groups = {} - show_browser = any(t.browser for t in tabs) - for tab in tabs: - browser_key = tab.browser or "local" - browser_node = browsers.setdefault(browser_key, root.add(f"[bold cyan]{browser_key}[/bold cyan]") if show_browser else root) - win_key = (browser_key, tab.window_id) - win_node = windows.get(win_key) - if win_node is None: - win_node = browser_node.add(f"Window {tab.window_id}") - windows[win_key] = win_node - group_label = f"Group {tab.group_id}" if tab.group_id is not None else "Ungrouped" - group_key = (browser_key, tab.window_id, group_label) - group_node = groups.get(group_key) - if group_node is None: - group_node = win_node.add(group_label) - groups[group_key] = group_node - active = " [green]*[/green]" if tab.active else "" - group_node.add(f"[{tab.id}] {tab.title or '(untitled)'}{active} — [dim]{tab.url or ''}[/dim]") - console.print(root) +def tabs_tree(show_urls): + """Show tabs grouped as a window/group tree.""" + client = client_from_ctx() + tabs = sorted(client.tabs.list(), key=_tab_sort_key) + title_limit = tree_title_limit(console=console, show_browser=any(t.browser for t in tabs), show_urls=show_urls) + url_limit = tree_url_limit(title_limit, console=console) + group_info = { + (group.browser or "local", group.id): group + for group in client.groups.list() + } + root = Tree("[bold]Tabs[/bold]") + browsers = {} + windows = {} + groups = {} + show_browser = any(t.browser for t in tabs) + for tab in tabs: + browser_key = tab.browser or "local" + browser_node = browsers.get(browser_key) + if browser_node is None: + browser_node = root.add(Text(browser_key, style="bold cyan")) if show_browser else root + browsers[browser_key] = browser_node + win_key = (browser_key, tab.window_id) + win_node = windows.get(win_key) + if win_node is None: + win_node = browser_node.add(f"Window {tab.window_id}") + windows[win_key] = win_node + if tab.group_id is None: + win_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit)) + continue + group_key = (browser_key, tab.window_id, tab.group_id) + group_node = groups.get(group_key) + group = group_info.get((browser_key, tab.group_id)) + if group_node is None: + group_node = win_node.add(_group_tree_label(tab.group_id, group, title_limit=title_limit)) + groups[group_key] = group_node + group_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit)) + print_tree(root, console=console) @tabs_group.command("close") @click.argument("tab_id", type=int, required=False) @@ -82,9 +121,9 @@ def tabs_tree(): @gentle_mode_option("Throttle mode for large close operations.") @handle_errors def tabs_close(tab_id, inactive, duplicates, gentle_mode): - """Close a tab, all inactive tabs, or all duplicate tabs.""" - count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode) - console.print(f"[green]Closed {count} tab(s)[/green]") + """Close a tab, all inactive tabs, or all duplicate tabs.""" + count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode) + console.print(f"[green]Closed {count} tab(s)[/green]") @tabs_group.command("move") @click.argument("tab_id", type=int) @@ -97,123 +136,123 @@ def tabs_close(tab_id, inactive, duplicates, gentle_mode): @click.option("--index", type=int, default=None, help="Absolute position index in target") @handle_errors def tabs_move(tab_id, forward, backward, group_id, window_id, index): - """Move a tab. Use --forward/--backward or --right/--left for relative movement.""" - client_from_ctx().tabs.move( - tab_id, forward=forward, backward=backward, - group_id=group_id, window_id=window_id, index=index, - ) - console.print("[green]Tab moved[/green]") + """Move a tab. Use --forward/--backward or --right/--left for relative movement.""" + client_from_ctx().tabs.move( + tab_id, forward=forward, backward=backward, + group_id=group_id, window_id=window_id, index=index, + ) + console.print("[green]Tab moved[/green]") @tabs_group.command("active") @click.argument("tab_id", type=int) @handle_errors def tabs_active(tab_id): - """Switch browser focus to a tab.""" - client_from_ctx().tabs.activate(tab_id) - console.print(f"[green]Switched to tab {tab_id}[/green]") + """Switch browser focus to a tab.""" + client_from_ctx().tabs.activate(tab_id) + console.print(f"[green]Switched to tab {tab_id}[/green]") @tabs_group.command("status") @click.argument("tab_id", type=int, required=False) @handle_errors def tabs_status(tab_id): - """Show status for the active tab or a specific tab.""" - tab = client_from_ctx().tabs.status(tab_id) - table = Table(show_header=False) - table.add_column("Field", style="bold cyan") - table.add_column("Value") - table.add_row("ID", str(tab.id)) - table.add_row("Window", str(tab.window_id)) - table.add_row("Active", "yes" if tab.active else "no") - table.add_row("Muted", "yes" if tab.muted else "no") - table.add_row("Title", tab.title or "") - table.add_row("URL", tab.url or "") - console.print(table) + """Show status for the active tab or a specific tab.""" + tab = client_from_ctx().tabs.status(tab_id) + table = Table(show_header=False) + table.add_column("Field", style="bold cyan") + table.add_column("Value") + table.add_row("ID", str(tab.id)) + table.add_row("Window", str(tab.window_id)) + table.add_row("Active", "yes" if tab.active else "no") + table.add_row("Muted", "yes" if tab.muted else "no") + table.add_row("Title", tab.title or "") + table.add_row("URL", tab.url or "") + console.print(table) @tabs_group.command("filter") @click.argument("pattern") @handle_errors def tabs_filter(pattern): - """List tabs whose URL contains PATTERN.""" - _print_tabs(client_from_ctx().tabs.filter(pattern)) + """List tabs whose URL contains PATTERN.""" + _print_tabs(client_from_ctx().tabs.filter(pattern)) @tabs_group.command("count") @click.argument("pattern", required=False) @handle_errors def tabs_count(pattern): - """Count open tabs, optionally filtered by URL PATTERN.""" - label = f" matching '{pattern}'" if pattern else "" - print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label) + """Count open tabs, optionally filtered by URL PATTERN.""" + label = f" matching '{pattern}'" if pattern else "" + print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label) @tabs_group.command("query") @click.argument("search") @handle_errors def tabs_query(search): - """Search tabs by URL or title.""" - _print_tabs(client_from_ctx().tabs.query(search)) + """Search tabs by URL or title.""" + _print_tabs(client_from_ctx().tabs.query(search)) @tabs_group.command("html") @click.argument("tab_id", type=int, required=False) @handle_errors def tabs_html(tab_id): - """Print the full HTML of a tab.""" - console.print(client_from_ctx().tabs.html(tab_id)) + """Print the full HTML of a tab.""" + console.print(client_from_ctx().tabs.html(tab_id)) @tabs_group.command("dedupe") @gentle_mode_option("Throttle mode for large dedupe operations.") @handle_errors def tabs_dedupe(gentle_mode): - """Close duplicate tabs (keep the first occurrence of each URL).""" - count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode) - console.print(f"[green]Closed {count} duplicate tab(s)[/green]") + """Close duplicate tabs (keep the first occurrence of each URL).""" + count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode) + console.print(f"[green]Closed {count} duplicate tab(s)[/green]") @tabs_group.command("sort") @click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True) @gentle_mode_option("Throttle mode for large sort operations.") @handle_errors def tabs_sort(by, gentle_mode): - """Sort tabs within each window.""" - client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode) - console.print(f"[green]Tabs sorted by {by}[/green]") + """Sort tabs within each window.""" + client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode) + console.print(f"[green]Tabs sorted by {by}[/green]") @tabs_group.command("merge-windows") @gentle_mode_option("Throttle mode for large merge operations.") @handle_errors def tabs_merge_windows(gentle_mode): - """Move all tabs into the focused window.""" - count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode) - console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]") + """Move all tabs into the focused window.""" + count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode) + console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]") @tabs_group.command("mute") @click.argument("tab_id", type=int, required=False) @handle_errors def tabs_mute(tab_id): - """Mute the active tab or a specific tab.""" - target = client_from_ctx().tabs.mute(tab_id) - console.print(f"[green]Muted tab {target}[/green]") + """Mute the active tab or a specific tab.""" + target = client_from_ctx().tabs.mute(tab_id) + console.print(f"[green]Muted tab {target}[/green]") @tabs_group.command("unmute") @click.argument("tab_id", type=int, required=False) @handle_errors def tabs_unmute(tab_id): - """Unmute the active tab or a specific tab.""" - target = client_from_ctx().tabs.unmute(tab_id) - console.print(f"[green]Unmuted tab {target}[/green]") + """Unmute the active tab or a specific tab.""" + target = client_from_ctx().tabs.unmute(tab_id) + console.print(f"[green]Unmuted tab {target}[/green]") @tabs_group.command("pin") @click.argument("tab_id", type=int, required=False) @handle_errors def tabs_pin(tab_id): - """Pin the active tab or a specific tab.""" - target = client_from_ctx().tabs.pin(tab_id) - console.print(f"[green]Pinned tab {target}[/green]") + """Pin the active tab or a specific tab.""" + target = client_from_ctx().tabs.pin(tab_id) + console.print(f"[green]Pinned tab {target}[/green]") @tabs_group.command("unpin") @click.argument("tab_id", type=int, required=False) @handle_errors def tabs_unpin(tab_id): - """Unpin the active tab or a specific tab.""" - target = client_from_ctx().tabs.unpin(tab_id) - console.print(f"[green]Unpinned tab {target}[/green]") + """Unpin the active tab or a specific tab.""" + target = client_from_ctx().tabs.unpin(tab_id) + console.print(f"[green]Unpinned tab {target}[/green]") @tabs_group.command("watch-url") @click.argument("pattern") @@ -221,9 +260,9 @@ def tabs_unpin(tab_id): @click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait") @handle_errors def tabs_watch_url(pattern, tab_id, timeout): - """Wait until the active (or specified) tab URL matches regex PATTERN.""" - tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout) - console.print(f"[green]URL matched:[/green] {tab.url}") + """Wait until the active (or specified) tab URL matches regex PATTERN.""" + tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout) + console.print(f"[green]URL matched:[/green] {tab.url}") @tabs_group.command("screenshot") @click.argument("output", required=False, metavar="FILE") @@ -232,21 +271,21 @@ def tabs_watch_url(pattern, tab_id, timeout): @click.option("--quality", type=int, default=None, help="JPEG quality 0-100") @handle_errors def tabs_screenshot(output, tab_id, fmt, quality): - """Capture a screenshot of the active (or specified) tab. + """Capture a screenshot of the active (or specified) tab. - Saves to FILE if given, otherwise prints the base64 data URL. - """ - data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality) - if output: - header = f"data:image/{fmt};base64," - if not data_url.startswith(header): - raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)") - try: - raw = base64.b64decode(data_url[len(header):]) - except binascii.Error as e: - raise click.ClickException(f"Failed to decode screenshot data: {e}") - with open(output, "wb") as f: - f.write(raw) - console.print(f"[green]Screenshot saved:[/green] {output}") - else: - console.print(data_url) + Saves to FILE if given, otherwise prints the base64 data URL. + """ + data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality) + if output: + header = f"data:image/{fmt};base64," + if not data_url.startswith(header): + raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)") + try: + raw = base64.b64decode(data_url[len(header):]) + except binascii.Error as e: + raise click.ClickException(f"Failed to decode screenshot data: {e}") + with open(output, "wb") as f: + f.write(raw) + console.print(f"[green]Screenshot saved:[/green] {output}") + else: + console.print(data_url) diff --git a/browser_cli/commands/windows.py b/browser_cli/commands/windows.py index 7e43c80..45ba3f0 100644 --- a/browser_cli/commands/windows.py +++ b/browser_cli/commands/windows.py @@ -1,5 +1,6 @@ import click from browser_cli.commands import client_from_ctx, handle_errors +from browser_cli.commands.rendering import print_tree, tab_tree_label, tree_title_limit, tree_url_limit from rich.console import Console from rich.table import Table from rich.tree import Tree @@ -7,81 +8,82 @@ from rich.tree import Tree console = Console() def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None: - if not windows: - console.print("[yellow]No windows found[/yellow]") - return - table = Table(show_header=True, header_style="bold cyan") - if show_browser: - table.add_column("Browser") - table.add_column("ID", style="dim", no_wrap=True) - table.add_column("Alias", width=20) - table.add_column("Tabs", width=6) - table.add_column("State", width=12) - for w in windows: - row = [ - w.get("browser", "") if show_browser else None, - str(w.get("id", "")), - w.get("alias") or "", - str(w.get("tabCount", "")), - w.get("state") or "", - ] - table.add_row(*[value for value in row if value is not None]) - console.print(table) + if not windows: + console.print("[yellow]No windows found[/yellow]") + return + table = Table(show_header=True, header_style="bold cyan") + if show_browser: + table.add_column("Browser") + table.add_column("ID", style="dim", no_wrap=True) + table.add_column("Alias", width=20) + table.add_column("Tabs", width=6) + table.add_column("State", width=12) + for w in windows: + row = [ + w.get("browser", "") if show_browser else None, + str(w.get("id", "")), + w.get("alias") or "", + str(w.get("tabCount", "")), + w.get("state") or "", + ] + table.add_row(*[value for value in row if value is not None]) + console.print(table) @click.group("windows") def windows_group(): - """Manage browser windows.""" + """Manage browser windows.""" @windows_group.command("list") @handle_errors def windows_list(): - """List all browser windows.""" - windows = client_from_ctx().windows.list() - _print_windows(windows, show_browser=any("browser" in w for w in windows)) + """List all browser windows.""" + windows = client_from_ctx().windows.list() + _print_windows(windows, show_browser=any("browser" in w for w in windows)) @windows_group.command("tree") @handle_errors def windows_tree(): - """Show windows and their tabs as a tree.""" - client = client_from_ctx() - windows = client.windows.list() - tabs = client.tabs.list() - root = Tree("[bold]Windows[/bold]") - for w in sorted(windows, key=lambda item: (item.get("browser", ""), item.get("id", 0))): - wid = w.get("id") - label = f"Window {wid}" - if w.get("alias"): - label += f" ({w['alias']})" - if w.get("browser"): - label = f"{w['browser']}: " + label - node = root.add(label) - for tab in sorted([t for t in tabs if t.window_id == wid and (not w.get("browser") or t.browser == w.get("browser"))], key=lambda t: t.index): - active = " [green]*[/green]" if tab.active else "" - node.add(f"[{tab.id}] {tab.title or '(untitled)'}{active} — [dim]{tab.url or ''}[/dim]") - console.print(root) + """Show windows and their tabs as a tree.""" + client = client_from_ctx() + windows = client.windows.list() + tabs = client.tabs.list() + root = Tree("[bold]Windows[/bold]") + 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) + for w in sorted(windows, key=lambda item: (item.get("browser", ""), item.get("id", 0))): + wid = w.get("id") + label = f"Window {wid}" + if w.get("alias"): + label += f" ({w['alias']})" + if w.get("browser"): + label = f"{w['browser']}: " + label + node = root.add(label) + for tab in sorted([t for t in tabs if t.window_id == wid and (not w.get("browser") or t.browser == w.get("browser"))], key=lambda t: getattr(t, "index", 0)): + node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit)) + print_tree(root, console=console) @windows_group.command("rename") @click.argument("window_id", type=int) @click.argument("name") @handle_errors def windows_rename(window_id, name): - """Give a window a local alias NAME (stored in native host).""" - client_from_ctx().windows.rename(window_id, name) - console.print(f"[green]Window {window_id} aliased as '{name}'[/green]") + """Give a window a local alias NAME (stored in native host).""" + client_from_ctx().windows.rename(window_id, name) + console.print(f"[green]Window {window_id} aliased as '{name}'[/green]") @windows_group.command("close") @click.argument("window_id", type=int) @handle_errors def windows_close(window_id): - """Close a browser window.""" - client_from_ctx().windows.close(window_id) - console.print(f"[green]Window {window_id} closed[/green]") + """Close a browser window.""" + client_from_ctx().windows.close(window_id) + console.print(f"[green]Window {window_id} closed[/green]") @windows_group.command("open") @click.argument("url", required=False) @handle_errors def windows_open(url): - """Open a new browser window.""" - result = client_from_ctx().windows.open(url) - wid = result.get("id") if isinstance(result, dict) else result - console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else "")) + """Open a new browser window.""" + result = client_from_ctx().windows.open(url) + wid = result.get("id") if isinstance(result, dict) else result + console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else "")) diff --git a/browser_cli/models.py b/browser_cli/models.py index 6ece007..6b5f1a4 100644 --- a/browser_cli/models.py +++ b/browser_cli/models.py @@ -44,6 +44,7 @@ class Tab: title: str = "" url: str = "" group_id: int | None = None + index: int = 0 browser: str | None = None _browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False) @@ -149,6 +150,7 @@ class Group: color: str collapsed: bool tab_count: int + window_id: int | None = None browser: str | None = None _browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False) diff --git a/browser_cli/sdk/factories.py b/browser_cli/sdk/factories.py index ec81124..da6a66b 100644 --- a/browser_cli/sdk/factories.py +++ b/browser_cli/sdk/factories.py @@ -37,6 +37,7 @@ class FactoryMixin: title=data.get("title") or "", url=data.get("url") or "", group_id=data.get("groupId") or None, + index=data.get("index", 0) or 0, browser=browser_name, ) client = cast(_FactoryClient, self) @@ -68,6 +69,7 @@ class FactoryMixin: color=data.get("color") or "", collapsed=data.get("collapsed", False), tab_count=data.get("tabCount", 0), + window_id=data.get("windowId"), browser=browser_name, ) client = cast(_FactoryClient, self) diff --git a/extension/src/core/tab-helpers.ts b/extension/src/core/tab-helpers.ts index f961d19..3e8f748 100644 --- a/extension/src/core/tab-helpers.ts +++ b/extension/src/core/tab-helpers.ts @@ -22,6 +22,7 @@ export function tabInfo(t: Tab) { windowId: t.windowId, active: t.active, muted: Boolean(t.mutedInfo && t.mutedInfo.muted), + index: t.index, groupId: t.groupId >= 0 ? t.groupId : null, title: t.title, url: t.url || t.pendingUrl || "", diff --git a/tests/test_api.py b/tests/test_api.py index 053993a..7fa690c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,6 +18,7 @@ TAB_DATA = { "id": 10, "windowId": 1, "active": True, + "index": 3, "title": "Example", "url": "https://example.com", "groupId": None, @@ -73,6 +74,15 @@ class TestBrowserCLIInit: assert b.remote == "browser-host.example:443" assert b.key == "agent" + def test_tab_factory_preserves_index(self): + tab = BrowserCLI().tab_from(TAB_DATA) + assert tab.index == 3 + + def test_tab_factory_defaults_missing_index_to_zero(self): + data = {key: value for key, value in TAB_DATA.items() if key != "index"} + tab = BrowserCLI().tab_from(data) + assert tab.index == 0 + def test_namespaces_present_and_bound(self): b = BrowserCLI() for name in ("nav", "tabs", "groups", "windows", "dom", "extract", diff --git a/tests/test_new_feature_commands.py b/tests/test_new_feature_commands.py index 4240a3e..70fd014 100644 --- a/tests/test_new_feature_commands.py +++ b/tests/test_new_feature_commands.py @@ -7,6 +7,7 @@ from click.testing import CliRunner import pytest from browser_cli import BrowserCLI +from browser_cli.client import BrowserTarget from browser_cli.cli import main from browser_cli.command_security import CommandPolicy, assert_command_allowed, command_category @@ -47,12 +48,94 @@ def test_nav_open_reuse_navigates_existing_tab_instead_of_opening_new(): ("navigate.to", {"tabId": 7, "url": "https://example.com"}), ] +def _tree_sender(tabs, groups): + def sender(command, args=None, **kwargs): + if command == "tabs.list": + return tabs + if command == "group.list": + return groups + return [] + return sender + def test_tabs_tree_command_available(): - with patch("browser_cli.send_command", return_value=[]): + with patch("browser_cli.send_command", side_effect=_tree_sender([], [])): result = CliRunner().invoke(main, ["tabs", "tree"]) assert result.exit_code == 0 assert "Tabs" in result.output +def test_tabs_tree_handles_tabs_without_index_from_older_extension(): + tabs = [{ + "id": 7, + "windowId": 1, + "active": True, + "muted": False, + "title": "Example", + "url": "https://example.com", + "groupId": None, + }] + with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])): + result = CliRunner().invoke(main, ["tabs", "tree"]) + assert result.exit_code == 0 + assert "Example" in result.output + +def test_tabs_tree_preserves_window_tab_order_and_truncates_long_lines(): + tabs = [ + {"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None}, + {"id": 2, "windowId": 1, "index": 1, "active": False, "title": "[Gold] Grouped", "url": "https://example.com/grouped", "groupId": 20}, + {"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/" + "x" * 200, "groupId": None}, + ] + groups = [{"id": 20, "title": "Group Name", "color": "blue", "collapsed": False, "tabCount": 1, "windowId": 1}] + with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)): + result = CliRunner().invoke(main, ["tabs", "tree"]) + assert result.exit_code == 0 + output = result.output + assert output.index("Before") < output.index("Group Name") < output.index("[Gold] Grouped") < output.index("After") + assert "https://example.com/before" not in output + assert "https://example.com/grouped" not in output + assert "https://example.com/" + "x" * 200 not in output + +def test_tabs_tree_adds_each_browser_node_only_once(): + tabs = [ + {"id": 1, "windowId": 1, "index": 0, "active": False, "title": "One", "url": "https://example.com/one", "groupId": None, "browser": "work"}, + {"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Two", "url": "https://example.com/two", "groupId": None, "browser": "work"}, + ] + targets = [ + BrowserTarget("work", "work", "/tmp/work.sock"), + BrowserTarget("personal", "personal", "/tmp/personal.sock"), + ] + with patch("browser_cli.active_browser_targets", return_value=targets), \ + patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])): + result = CliRunner().invoke(main, ["tabs", "tree"]) + assert result.exit_code == 0 + assert result.output.count("work") == 1 + assert result.output.count("personal") == 1 + assert "One" in result.output + assert "Two" in result.output + +def test_tabs_tree_shows_tabs_inside_collapsed_browser_groups(): + tabs = [ + {"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None}, + {"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Hidden", "url": "https://example.com/hidden", "groupId": 20}, + {"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/after", "groupId": None}, + ] + groups = [{"id": 20, "title": "Collapsed Group", "color": "orange", "collapsed": True, "tabCount": 1, "windowId": 1}] + with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)): + result = CliRunner().invoke(main, ["tabs", "tree"]) + assert result.exit_code == 0 + assert "Collapsed Group" in result.output + assert "1 tab" in result.output + assert "collapsed" in result.output + assert "Hidden" in result.output + +def test_tabs_tree_can_show_shortened_urls_on_request(): + tabs = [{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Long URL", "url": "https://example.com/" + "x" * 200, "groupId": None}] + with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])): + result = CliRunner().invoke(main, ["tabs", "tree", "--urls"]) + assert result.exit_code == 0 + assert "https://example.com/" in result.output + assert "https://example.com/" + "x" * 200 not in result.output + assert "…" in result.output + def test_doctor_command_reports_connection_failure_cleanly(): with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \ patch("browser_cli.send_command", side_effect=RuntimeError("no browser")): diff --git a/tests/test_rendering.py b/tests/test_rendering.py new file mode 100644 index 0000000..7136032 --- /dev/null +++ b/tests/test_rendering.py @@ -0,0 +1,34 @@ +from os import terminal_size + +from rich.console import Console +from rich.tree import Tree + +from browser_cli.commands import rendering + +def test_shorten_uses_ellipsis(): + assert rendering.shorten("abcdef", 4) == "abc…" + assert rendering.shorten("abc", 4) == "abc" + +def test_terminal_width_prefers_shell_width_when_rich_is_redirected(monkeypatch): + monkeypatch.setattr(rendering.shutil, "get_terminal_size", lambda fallback: terminal_size((140, 20))) + assert rendering.terminal_width(Console(width=80)) == 140 + +def test_tab_tree_label_is_reusable_no_wrap_text(): + tab = type("Tab", (), {"id": 1, "title": "abcdef", "active": True, "url": "https://example.com"})() + label = rendering.tab_tree_label(tab, title_limit=4, show_urls=True, url_limit=12) + assert label.no_wrap is True + assert label.overflow == "ellipsis" + assert "abc…" in label.plain + assert "https://exa…" in label.plain + +def test_print_tree_uses_detected_width(monkeypatch): + widths = [] + class CapturingConsole(Console): + def __init__(self, *args, **kwargs): + widths.append(kwargs.get("width")) + super().__init__(*args, **kwargs) + + monkeypatch.setattr(rendering, "Console", CapturingConsole) + monkeypatch.setattr(rendering, "terminal_width", lambda console=None: 132) + rendering.print_tree(Tree("Root")) + assert widths == [132]