0b43408a8d
- Add shared rendering helpers for width-aware tree labels, truncation, and no-wrap Rich text. - Preserve tab index and group window metadata through the extension and SDK factories. - Render tab trees in browser/window/index order with grouped tab details and optional shortened URLs. - Reuse the tab tree labels in window trees to keep output compact and consistent. - Cover legacy missing-index responses, grouped/collapsed tabs, URL display, and rendering helpers with tests.
292 lines
11 KiB
Python
292 lines
11 KiB
Python
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)
|
|
|
|
@click.group("tabs")
|
|
def tabs_group():
|
|
"""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))
|
|
|
|
@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_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)
|
|
@click.option("--inactive", is_flag=True, help="Close all inactive tabs")
|
|
@click.option("--duplicates", is_flag=True, help="Close duplicate tabs (keep first)")
|
|
@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]")
|
|
|
|
@tabs_group.command("move")
|
|
@click.argument("tab_id", type=int)
|
|
@click.option("-f", "--forward", "forward", is_flag=True, help="Move one position to the right")
|
|
@click.option("-b", "--backward", "backward", is_flag=True, help="Move one position to the left")
|
|
@click.option("-r", "--right", "forward", is_flag=True, help="Move one position to the right")
|
|
@click.option("-l", "--left", "backward", is_flag=True, help="Move one position to the left")
|
|
@click.option("--group", "group_id", type=int, default=None, help="Move to tab group ID")
|
|
@click.option("--window", "window_id", type=int, default=None, help="Move to window ID")
|
|
@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]")
|
|
|
|
@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]")
|
|
|
|
@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)
|
|
|
|
@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))
|
|
|
|
@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)
|
|
|
|
@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))
|
|
|
|
@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))
|
|
|
|
@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]")
|
|
|
|
@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]")
|
|
|
|
@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]")
|
|
|
|
@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]")
|
|
|
|
@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]")
|
|
|
|
@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]")
|
|
|
|
@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]")
|
|
|
|
@tabs_group.command("watch-url")
|
|
@click.argument("pattern")
|
|
@tab_option
|
|
@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}")
|
|
|
|
@tabs_group.command("screenshot")
|
|
@click.argument("output", required=False, metavar="FILE")
|
|
@tab_option
|
|
@click.option("--format", "fmt", type=click.Choice(["png", "jpeg"]), default="png", show_default=True)
|
|
@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.
|
|
|
|
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)
|