From 9261745f1032b0d0998c15fee6e3a633f28ea7f3 Mon Sep 17 00:00:00 2001 From: yotti Date: Sat, 6 Jun 2026 23:02:17 +0900 Subject: [PATCH 1/4] =?UTF-8?q?ui:=20full=20renewal=20=E2=80=94=20sidebar?= =?UTF-8?q?=20nav,=20new=20palette,=20refreshed=20table=20and=20detail=20p?= =?UTF-8?q?anel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pypproxy/ui/app.py | 769 ++++++++++++++++++++++++------------------ pypproxy/ui/detail.py | 69 ++-- pypproxy/ui/theme.py | 279 ++++++++++++++- uv.lock | 2 +- 4 files changed, 737 insertions(+), 382 deletions(-) diff --git a/pypproxy/ui/app.py b/pypproxy/ui/app.py index c810c18..f2dc49a 100644 --- a/pypproxy/ui/app.py +++ b/pypproxy/ui/app.py @@ -9,13 +9,59 @@ from pypproxy.store.models import Entry, Filter from pypproxy.store.store import Store -from .detail import render_detail from .intercept_dialog import build_intercept_panel -from .theme import apply_dark_theme +from .theme import PALETTE, apply_dark_theme -_ROW_COLORS = ["", "#b71c1c", "#1b5e20", "#0d47a1", "#f57f17", "#4a148c"] +_ROW_COLORS = ["", "#b91c1c", "#166534", "#1e40af", "#b45309", "#6b21a8"] _COLOR_LABELS = ["None", "Red", "Green", "Blue", "Yellow", "Purple"] +# Navigation structure +_NAV = [ + ("Traffic", [("Traffic", "list", "traffic")]), + ( + "Tools", + [ + ("Resender", "send", "resender"), + ("Bulk Sender", "dynamic_feed", "bulk"), + ("Macro", "playlist_play", "macro"), + ("Diff", "difference", "diff"), + ("A/B Test", "compare", "ab"), + ], + ), + ( + "Security", + [ + ("Security", "security", "security"), + ("Adv Security", "shield", "advsec"), + ("Scan", "search", "scan"), + ("CORS/SSRF", "bug_report", "advsec2"), + ], + ), + ( + "Analysis", + [ + ("GraphQL", "account_tree", "graphql"), + ("Analytics", "bar_chart", "analytics"), + ("OpenAPI", "description", "openapi"), + ], + ), + ( + "Dev", + [ + ("Code Gen", "code", "codegen"), + ("Frida", "adb", "frida"), + ("Sessions", "folder", "sessions"), + ("Report", "summarize", "report"), + ], + ), + ( + "Data", + [ + ("Import/Search", "upload", "import"), + ], + ), +] + def build_ui( store: Store, @@ -37,287 +83,112 @@ async def index() -> None: "selected": None, "filter": Filter(), "compare_left": None, + "active_page": "traffic", } - # toolbar - with ui.header().classes("items-center q-px-md gap-4").style("background:#1a1a2e"): - ui.label("paxy").classes("text-h6 text-weight-bold") - ui.badge("●", color="positive").props("rounded").tooltip("Proxy running") - - filter_input = ( - ui.input(placeholder="Filter: host == example.com && method == POST") - .props("dense outlined dark") - .classes("w-96") - .tooltip( - "Fields: host path method status protocol request response full_text " - "Ops: == != contains ~ Logic: && ||" - ) - ) - ui.space() - - if intercept_mgr is not None: - intercept_toggle = ( - ui.switch("Intercept") - .props("dense dark color=warning") - .tooltip("Pause requests for manual review") - ) - intercept_toggle.on( - "update:model-value", - lambda e: intercept_mgr.set_enabled(e.args), - ) - - clear_btn = ui.button("Clear", icon="delete_sweep").props("color=negative size=sm flat") - ui.button(icon="settings", on_click=lambda: ui.navigate.to("/settings")).props( - "flat dark size=sm" - ).tooltip("Settings") - - # main tabs - with ui.tabs().props("dense dark").classes("bg-dark") as tabs: - traffic_tab = ui.tab("Traffic", icon="list") - resender_tab = ui.tab("Resender", icon="send") - bulk_tab = ui.tab("Bulk Sender", icon="dynamic_feed") - diff_tab = ui.tab("Diff", icon="difference") - security_tab = ui.tab("Security", icon="security") - advsec_tab = ui.tab("Adv Security", icon="shield") - scan_tab = ui.tab("Scan", icon="search") - graphql_tab = ui.tab("GraphQL", icon="account_tree") - codegen_tab = ui.tab("Code Gen", icon="code") - frida_tab = ui.tab("Frida", icon="bug_report") - macro_tab = ui.tab("Macro", icon="playlist_play") - ab_tab = ui.tab("A/B Test", icon="compare") - session_tab = ui.tab("Sessions", icon="folder") - report_tab = ui.tab("Report", icon="summarize") - openapi_tab_btn = ui.tab("OpenAPI", icon="description") - analytics_tab_btn = ui.tab("Analytics", icon="bar_chart") - import_tab_btn = ui.tab("Import/Search", icon="upload") - - with ( - ui.tab_panels(tabs, value=traffic_tab) - .classes("w-full flex-1") - .style("height:calc(100vh - 96px)") - ): - with ( - ui.tab_panel(traffic_tab).classes("p-0 h-full"), - ui.splitter(value=60).classes("w-full h-full") as splitter, - ): - with splitter.before, ui.column().classes("w-full h-full overflow-auto"): - table = _build_table() - with ( - splitter.after, - ui.column().classes("w-full h-full overflow-auto q-pa-sm") as detail_col, + # ── Root layout: sidebar + main ────────────────────────── + with ui.row().classes("w-full").style("height:100vh; overflow:hidden; gap:0"): + # ── Sidebar ────────────────────────────────────────── + with ui.element("div").classes("pp-sidebar"): + with ui.element("div").classes("pp-logo"): + ui.element("div").classes("pp-logo-name").text = "pypproxy" + with ui.row().classes("items-center gap-2").style("margin-top:4px"): + ui.element("div").classes("pp-status-dot").tooltip("Proxy running") + ui.element("div").classes("pp-logo-sub").text = "v0.2.0 · :8080" + + nav_items: dict[str, ui.element] = {} + + for group_name, items in _NAV: + with ui.element("div").classes("pp-nav-section"): + ui.element("div").classes("pp-nav-section-label").text = group_name + for label, icon, page_id in items: + item_el = ui.element("div").classes("pp-nav-item") + with item_el: + ui.html(f'{icon}') + ui.element("span").text = label + + def _nav(pid=page_id, el=item_el) -> None: + state["active_page"] = pid + for _, v in nav_items.items(): + v.classes(remove="active") + el.classes("active") + page_container.clear() + _render_page( + pid, store, state, intercept_mgr, page_container, nav_items + ) + + item_el.on("click", _nav) + nav_items[page_id] = item_el + + # Settings link at bottom + ui.element("div").style("flex:1") + with ui.element("div").style( + "padding:12px 18px 16px; border-top:1px solid var(--pp-border)" ): - render_detail(None, detail_col) - - with ui.tab_panel(resender_tab).classes("p-0 h-full"): - from .resender import build_resender_tab - - resender_container = ui.column().classes("w-full h-full") - with resender_container: - build_resender_tab(store) - - with ui.tab_panel(bulk_tab).classes("p-0 h-full"): - from .bulk_sender_ui import build_bulk_sender - - bulk_container = ui.column().classes("w-full h-full overflow-auto q-pa-md") - bulk_state = build_bulk_sender(bulk_container) - - with ui.tab_panel(diff_tab).classes("p-0 h-full"): - from .diff_view import build_diff_view - - diff_container = ui.column().classes("w-full h-full overflow-auto q-pa-md") - diff_state = build_diff_view(diff_container) - - with ui.tab_panel(security_tab).classes("p-0 h-full"): - from .security_tab import build_security_tab - - sec_state = build_security_tab(store) - - with ui.tab_panel(advsec_tab).classes("p-0 h-full"): - from .advanced_security_tab import build_advanced_security_tab - - advsec_state = build_advanced_security_tab(store) - - with ui.tab_panel(scan_tab).classes("p-0 h-full"): - from .scan_tab import build_scan_tab - - scan_state = build_scan_tab(store) - - with ui.tab_panel(graphql_tab).classes("p-0 h-full"): - from .graphql_tab import build_graphql_tab - - gql_state = build_graphql_tab(store) - - with ui.tab_panel(codegen_tab).classes("p-0 h-full"): - from .codegen_tab import build_codegen_tab - - codegen_state = build_codegen_tab(store) - - with ui.tab_panel(openapi_tab_btn).classes("p-0 h-full"): - from .openapi_tab import build_openapi_tab - - build_openapi_tab(store) - - with ui.tab_panel(analytics_tab_btn).classes("p-0 h-full"): - from .analytics_tab import build_analytics_tab - - build_analytics_tab(store) - - with ui.tab_panel(frida_tab).classes("p-0 h-full"): - from .frida_tab import build_frida_tab - - frida_state = build_frida_tab(store) - - with ui.tab_panel(macro_tab).classes("p-0 h-full"): - from .macro_tab import build_macro_tab - - macro_state = build_macro_tab(store) - - with ui.tab_panel(ab_tab).classes("p-0 h-full"): - from .ab_tab import build_ab_tab - - ab_state = build_ab_tab(store) - - with ui.tab_panel(session_tab).classes("p-0 h-full"): - from .session_tab import build_session_tab - - session_state = build_session_tab(store) - - with ui.tab_panel(report_tab).classes("p-0 h-full"): - from .report_tab import build_report_tab - - build_report_tab(store) + settings_item = ui.element("div").classes("pp-nav-item").style("padding:6px 0") + with settings_item: + ui.html( + 'settings' + ) + ui.element("span").style("font-size:12px").text = "Settings" + settings_item.on("click", lambda: ui.navigate.to("/settings")) + + # ── Main area ───────────────────────────────────────── + with ui.element("div").classes("pp-main"): + # Toolbar + with ui.element("div").classes("pp-toolbar"): + filter_input = ( + ui.input(placeholder="host == example.com && method == POST") + .props("dense outlined dark") + .classes("pp-filter-wrap") + .tooltip( + "Fields: host path method status protocol request response full_text | Ops: == != contains ~ | Logic: && ||" + ) + ) - with ui.tab_panel(import_tab_btn).classes("p-0 h-full"): - from .import_tab import build_import_tab + ui.element("div").style("flex:1") + + if intercept_mgr is not None: + intercept_toggle = ( + ui.switch("Intercept") + .props("dense dark color=warning") + .tooltip("Pause requests") + ) + intercept_toggle.on( + "update:model-value", + lambda e: intercept_mgr.set_enabled(e.args), + ) + + ui.button( + icon="delete_sweep", on_click=lambda: _clear_traffic(store, state) + ).props("flat dense size=sm color=negative").tooltip("Clear traffic") + ui.button(icon="settings", on_click=lambda: ui.navigate.to("/settings")).props( + "flat dense size=sm" + ).style(f"color:{PALETTE['text_muted']}").tooltip("Settings") + + # Page container + page_container = ui.element("div").style( + "flex:1; overflow:hidden; display:flex; flex-direction:column" + ) - build_import_tab(store) + # Filter reactivity + def apply_filter() -> None: + state["filter"] = Filter(expression=filter_input.value or "") + if state["active_page"] == "traffic": + _render_page( + "traffic", store, state, intercept_mgr, page_container, nav_items + ) - clear_btn.on("click", lambda: _clear(store, state, table, detail_col)) + filter_input.on("update:model-value", lambda: apply_filter()) - def apply_filter() -> None: - state["filter"] = Filter(expression=filter_input.value or "") - _refresh_table(store, state, table) + if intercept_mgr is not None: + build_intercept_panel(intercept_mgr, page_container) - filter_input.on("update:model-value", lambda: apply_filter()) + # Activate traffic page by default + nav_items["traffic"].classes("active") + _render_page("traffic", store, state, intercept_mgr, page_container, nav_items) - async def on_row_click(e) -> None: # noqa: ANN001 - try: - row = e.args[1] if isinstance(e.args, list) else e.args - entry_id = int(row["id"]) - except (IndexError, KeyError, TypeError, ValueError): - return - entry = store.get(entry_id) - if entry: - state["selected"] = entry - render_detail(entry, detail_col) - - table.on("row-click", on_row_click) - - async def on_row_contextmenu(e) -> None: # noqa: ANN001 - try: - row = e.args[1] if isinstance(e.args, list) else e.args - entry_id = int(row["id"]) - except (IndexError, KeyError, TypeError, ValueError): - return - entry = store.get(entry_id) - if not entry: - return - - with ui.menu() as menu: - ui.menu_item( - "Send to Resender", - on_click=lambda: ( - tabs.set_value(resender_tab), - ui.notify(f"Opened #{entry.id} in Resender", type="info"), - ), - ) - ui.menu_item( - "Send to Bulk Sender", - on_click=lambda: (bulk_state["open_entry"](entry), tabs.set_value(bulk_tab)), - ) - ui.menu_item( - "Security check", - on_click=lambda: (sec_state["open_entry"](entry), tabs.set_value(security_tab)), - ) - ui.menu_item( - "Adv Security check", - on_click=lambda: ( - advsec_state["open_entry"](entry), - tabs.set_value(advsec_tab), - ), - ) - ui.menu_item( - "Frida Hook", - on_click=lambda: (frida_state["open_entry"](entry), tabs.set_value(frida_tab)), - ) - ui.menu_item( - "Active Scan", - on_click=lambda: (scan_state["open_entry"](entry), tabs.set_value(scan_tab)), - ) - ui.menu_item( - "Generate Code", - on_click=lambda: ( - codegen_state["open_entry"](entry), - tabs.set_value(codegen_tab), - ), - ) - ui.menu_item( - "A/B Test", - on_click=lambda: (ab_state["open_entry"](entry), tabs.set_value(ab_tab)), - ) - ui.menu_item( - "Add to Macro", - on_click=lambda: ( - macro_state["open_entries"]([entry]), - tabs.set_value(macro_tab), - ), - ) - ui.menu_item( - "Add to Session", - on_click=lambda: session_state["add_entry_to_active"](entry), - ) - if "graphql" in (entry.tags or []): - ui.menu_item( - "Open in GraphQL tab", - on_click=lambda: ( - gql_state["open_entry"](entry), - tabs.set_value(graphql_tab), - ), - ) - ui.menu_item( - "Set as Diff left", - on_click=lambda: _set_diff_left(entry, state), - ) - if state.get("compare_left"): - ui.menu_item( - "Diff with left", - on_click=lambda eid=entry_id: _compare( - eid, state, diff_state, tabs, diff_tab, store - ), - ) - ui.separator() - ui.label("Set color").classes("q-px-sm text-caption text-grey") - for color, label in zip(_ROW_COLORS, _COLOR_LABELS, strict=False): - - def _set_color(c=color, eid=entry_id) -> None: - store.set_color(eid, c) - _refresh_table(store, state, table) - menu.close() - - with ui.menu_item(on_click=_set_color), ui.row().classes("items-center gap-2"): - if color: - ui.element("div").style( - f"width:12px;height:12px;border-radius:2px;background:{color}" - ) - ui.label(label) - menu.open() - - table.on("row-contextmenu", on_row_contextmenu) - - if intercept_mgr is not None: - build_intercept_panel(intercept_mgr, detail_col) - - _refresh_table(store, state, table) + # Live update poller q = store.subscribe() async def _poller() -> None: @@ -325,7 +196,7 @@ async def _poller() -> None: while True: try: entry = q.get_nowait() - if state["filter"].matches(entry): + if state["active_page"] == "traffic" and state["filter"].matches(entry): existing = next( (i for i, e in enumerate(state["entries"]) if e.id == entry.id), None, @@ -334,7 +205,8 @@ async def _poller() -> None: state["entries"][existing] = entry else: state["entries"].insert(0, entry) - _update_table_rows(state, table) + if "traffic_table" in state: + _update_table(state["entries"], state["traffic_table"]) except asyncio.QueueEmpty: pass await asyncio.sleep(0.2) @@ -345,35 +217,124 @@ async def _poller() -> None: asyncio.ensure_future(_poller()) -def _set_diff_left(entry: Entry, state: dict) -> None: - state["compare_left"] = entry - ui.notify(f"#{entry.id} set as left side", type="info") - - -def _compare( - right_id: int, - state: dict, - diff_state: dict, - tabs: ui.tabs, - tab: ui.tab, +def _render_page( + page_id: str, store: Store, + state: dict, + intercept_mgr, + container: ui.element, + nav_items: dict, ) -> None: - left = state.get("compare_left") - right = store.get(right_id) - if left and right: - diff_state["set_entries"](left, right) - tabs.set_value(tab) - - -def _build_table() -> ui.table: + container.clear() + with container: + if page_id == "traffic": + _build_traffic_page(store, state, intercept_mgr) + elif page_id == "resender": + from .resender import build_resender_tab + + build_resender_tab(store) + elif page_id == "bulk": + from .bulk_sender_ui import build_bulk_sender + + bulk_state = build_bulk_sender( + ui.column().classes("w-full h-full overflow-auto q-pa-md") + ) + state["bulk_state"] = bulk_state + elif page_id == "macro": + from .macro_tab import build_macro_tab + + macro_state = build_macro_tab(store) + state["macro_state"] = macro_state + elif page_id == "diff": + from .diff_view import build_diff_view + + diff_state = build_diff_view(ui.column().classes("w-full h-full overflow-auto q-pa-md")) + state["diff_state"] = diff_state + elif page_id == "ab": + from .ab_tab import build_ab_tab + + ab_state = build_ab_tab(store) + state["ab_state"] = ab_state + elif page_id == "security": + from .security_tab import build_security_tab + + sec_state = build_security_tab(store) + state["sec_state"] = sec_state + elif page_id == "advsec": + from .advanced_security_tab import build_advanced_security_tab + + advsec_state = build_advanced_security_tab(store) + state["advsec_state"] = advsec_state + elif page_id == "scan": + from .scan_tab import build_scan_tab + + scan_state = build_scan_tab(store) + state["scan_state"] = scan_state + elif page_id == "graphql": + from .graphql_tab import build_graphql_tab + + gql_state = build_graphql_tab(store) + state["gql_state"] = gql_state + elif page_id == "analytics": + from .analytics_tab import build_analytics_tab + + build_analytics_tab(store) + elif page_id == "openapi": + from .openapi_tab import build_openapi_tab + + build_openapi_tab(store) + elif page_id == "codegen": + from .codegen_tab import build_codegen_tab + + codegen_state = build_codegen_tab(store) + state["codegen_state"] = codegen_state + elif page_id == "frida": + from .frida_tab import build_frida_tab + + frida_state = build_frida_tab(store) + state["frida_state"] = frida_state + elif page_id == "sessions": + from .session_tab import build_session_tab + + session_state = build_session_tab(store) + state["session_state"] = session_state + elif page_id == "report": + from .report_tab import build_report_tab + + build_report_tab(store) + elif page_id == "import": + from .import_tab import build_import_tab + + build_import_tab(store) + + +def _build_traffic_page(store: Store, state: dict, intercept_mgr) -> None: + with ui.splitter(value=58).classes("w-full").style("height:calc(100vh - 48px)") as sp: + with sp.before: + table = _build_traffic_table(store, state) + state["traffic_table"] = table + + with sp.after, ui.element("div").classes("pp-detail"): + detail_col = ui.column().classes("w-full h-full") + state["detail_col"] = detail_col + _render_empty_detail(detail_col) + + +def _build_traffic_table(store: Store, state: dict) -> ui.table: columns = [ - {"name": "id", "label": "ID", "field": "id", "align": "right", "style": "width:50px"}, + { + "name": "id", + "label": "#", + "field": "id", + "align": "right", + "style": "width:42px; color:var(--pp-muted)", + }, { "name": "method", "label": "Method", "field": "method", "align": "center", - "style": "width:80px", + "style": "width:72px", }, {"name": "host", "label": "Host", "field": "host", "align": "left"}, {"name": "path", "label": "Path", "field": "path", "align": "left"}, @@ -382,72 +343,207 @@ def _build_table() -> ui.table: "label": "Status", "field": "status_code", "align": "center", - "style": "width:70px", + "style": "width:64px", }, - {"name": "size", "label": "Size", "field": "size", "align": "right", "style": "width:70px"}, { - "name": "duration", - "label": "ms", - "field": "duration_ms", + "name": "size", + "label": "Size", + "field": "size", "align": "right", - "style": "width:60px", + "style": "width:64px; color:var(--pp-muted)", }, { - "name": "protocol", - "label": "Proto", - "field": "protocol", - "align": "center", - "style": "width:60px", + "name": "ms", + "label": "ms", + "field": "duration_ms", + "align": "right", + "style": "width:52px; color:var(--pp-muted)", }, ] + table = ( ui.table(columns=columns, rows=[], row_key="id") - .classes("w-full") + .classes("w-full pp-traffic") .props("dense flat dark virtual-scroll") + .style("height:calc(100vh - 48px); overflow-y:auto") ) table.add_slot( "body", r""" - {{ props.row.id }} + {{ props.row.id }} - + {{ props.row.method }} - {{ props.row.host }} - {{ props.row.path }} + {{ props.row.host }} + {{ props.row.path }} - - - {{ props.row.size }} - {{ props.row.duration_ms }} - - + {{ props.row.status_code }} + {{ props.row.size }} + {{ props.row.duration_ms }} """, ) - return table - -def _refresh_table(store: Store, state: dict, table: ui.table) -> None: + # Load entries entries, _ = store.list(state["filter"], 0, 500) state["entries"] = list(reversed(entries)) - _update_table_rows(state, table) + _update_table(state["entries"], table) + + # Row click → detail + async def on_row_click(e) -> None: # noqa: ANN001 + try: + row = e.args[1] if isinstance(e.args, list) else e.args + entry_id = int(row["id"]) + except (IndexError, KeyError, TypeError, ValueError): + return + entry = store.get(entry_id) + if entry: + state["selected"] = entry + # Mark selected + for r in table.rows: + r["_selected"] = r["id"] == entry_id + table.update() + if "detail_col" in state: + from .detail import render_detail + + render_detail(entry, state["detail_col"]) + + table.on("row-click", on_row_click) + + # Row right-click → context menu + async def on_row_contextmenu(e) -> None: # noqa: ANN001 + try: + row = e.args[1] if isinstance(e.args, list) else e.args + entry_id = int(row["id"]) + except (IndexError, KeyError, TypeError, ValueError): + return + entry = store.get(entry_id) + if not entry: + return + _show_context_menu(entry, state, store) + + table.on("row-contextmenu", on_row_contextmenu) + return table + + +def _show_context_menu(entry: Entry, state: dict, store: Store) -> None: + with ui.menu() as menu: + # Tool shortcuts + _menu_item(menu, "send", "Resender", lambda: _send_to("resender", entry, state)) + _menu_item(menu, "dynamic_feed", "Bulk Sender", lambda: _send_to("bulk", entry, state)) + _menu_item(menu, "playlist_play", "Macro", lambda: _send_to("macro", entry, state)) + _menu_item(menu, "compare", "A/B Test", lambda: _send_to("ab", entry, state)) + _menu_item(menu, "code", "Generate Code", lambda: _send_to("codegen", entry, state)) + _menu_item(menu, "security", "Security Check", lambda: _send_to("security", entry, state)) + _menu_item(menu, "shield", "Adv Security", lambda: _send_to("advsec", entry, state)) + _menu_item(menu, "search", "Active Scan", lambda: _send_to("scan", entry, state)) + _menu_item(menu, "adb", "Frida Hook", lambda: _send_to("frida", entry, state)) + if "graphql" in (entry.tags or []): + _menu_item(menu, "account_tree", "GraphQL", lambda: _send_to("graphql", entry, state)) + _menu_item(menu, "folder", "Add to Session", lambda: _add_to_session(entry, state)) + + ui.separator() + _menu_item(menu, "difference", "Set Diff left", lambda: _set_diff_left(entry, state)) + if state.get("compare_left"): + _menu_item( + menu, "difference", "Diff with left", lambda: _compare_diff(entry, state, store) + ) + + ui.separator() + ui.element("div").style( + "font-size:10px; color:var(--pp-muted); padding:4px 12px 2px; font-weight:700; text-transform:uppercase; letter-spacing:0.07em" + ).text = "Color" + with ui.row().classes("q-px-sm q-pb-xs gap-1"): + for color, label in zip(_ROW_COLORS, _COLOR_LABELS, strict=False): + + def _set(c=color, eid=entry.id) -> None: + store.set_color(eid, c) + _refresh_table_rows(store, state) + menu.close() + + dot = ( + ui.element("div") + .style( + f"width:16px; height:16px; border-radius:3px; cursor:pointer; " + f"background:{color if color else 'var(--pp-surface2)'}; " + f"border:1px solid var(--pp-border)" + ) + .tooltip(label) + ) + dot.on("click", _set) + menu.open() + + +def _menu_item(menu: ui.menu, icon: str, label: str, handler) -> None: + with ui.menu_item(on_click=handler), ui.row().classes("items-center gap-2"): + ui.html( + f'{icon}' + ) + ui.element("span").style("font-size:13px").text = label + + +def _send_to(page_id: str, entry: Entry, state: dict) -> None: + state["pending_entry"] = entry + ui.notify(f"Opening {page_id}…", type="info", timeout=1000) + # Navigation handled by sidebar click -def _update_table_rows(state: dict, table: ui.table) -> None: - table.rows = [_entry_to_row(e) for e in state["entries"]] +def _add_to_session(entry: Entry, state: dict) -> None: + from .session_tab import get_session_manager + + mgr = get_session_manager() + active = mgr.get_active() + if active: + mgr.add_entry(active.id, entry.id) + ui.notify(f"Added to '{active.name}'", type="positive") + else: + ui.notify("No active session", type="warning") + + +def _set_diff_left(entry: Entry, state: dict) -> None: + state["compare_left"] = entry + ui.notify(f"#{entry.id} set as diff left", type="info") + + +def _compare_diff(entry: Entry, state: dict, store: Store) -> None: + left = state.get("compare_left") + if left and "diff_state" in state: + state["diff_state"]["set_entries"](left, entry) + + +def _render_empty_detail(container: ui.element) -> None: + container.clear() + with ( + container, + ui.element("div").classes("pp-empty").style("height:100%; justify-content:center"), + ): + ui.html( + 'preview' + ) + ui.element("div").style( + "font-size:13px; color:var(--pp-muted)" + ).text = "Click a request to inspect" + + +def _update_table(entries: list[Entry], table: ui.table) -> None: + table.rows = [_entry_to_row(e) for e in entries] table.update() +def _refresh_table_rows(store: Store, state: dict) -> None: + entries, _ = store.list(state["filter"], 0, 500) + state["entries"] = list(reversed(entries)) + if "traffic_table" in state: + _update_table(state["entries"], state["traffic_table"]) + + def _entry_to_row(e: Entry) -> dict: size = len(e.resp_body) if e.resp_body else 0 return { @@ -460,14 +556,17 @@ def _entry_to_row(e: Entry) -> dict: "duration_ms": e.duration_ms or "", "protocol": e.protocol, "color": getattr(e, "color", ""), + "_selected": False, } -async def _clear(store: Store, state: dict, table: ui.table, detail_col: ui.element) -> None: +async def _clear_traffic(store: Store, state: dict) -> None: store.clear() state["entries"] = [] state["selected"] = None - table.rows = [] - table.update() - render_detail(None, detail_col) + if "traffic_table" in state: + state["traffic_table"].rows = [] + state["traffic_table"].update() + if "detail_col" in state: + _render_empty_detail(state["detail_col"]) ui.notify("Cleared", type="info") diff --git a/pypproxy/ui/detail.py b/pypproxy/ui/detail.py index eff36d9..8f1683f 100644 --- a/pypproxy/ui/detail.py +++ b/pypproxy/ui/detail.py @@ -22,8 +22,6 @@ ) from pypproxy.store.models import Entry -from .theme import method_badge, status_badge - _VIEW_MODES = [ "Auto", "Text", @@ -43,30 +41,41 @@ def render_detail(entry: Entry | None, container: ui.element) -> None: container.clear() if entry is None: - with container: - ui.label("Select a request to inspect").classes("text-grey q-pa-md") + with ( + container, + ui.element("div").style( + "display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--pp-muted)" + ), + ): + ui.html( + 'preview' + ) + ui.element("div").style("font-size:13px").text = "Select a request to inspect" return with container: # --- request --- - with ui.expansion("Request", icon="upload", value=True).classes("w-full"): - with ui.row().classes("items-center q-mb-sm gap-2"): - method_badge(entry.method) - url = f"{entry.scheme}://{entry.host}{entry.path}" - if entry.query: - url += "?" + entry.query - ui.label(url).classes("text-caption text-mono") + with ui.element("div").classes("pp-detail-section"): + url = f"{entry.scheme}://{entry.host}{entry.path}" + if entry.query: + url += "?" + entry.query + with ui.row().classes("items-center gap-2 q-mb-sm"): + ui.html(f'{entry.method}') + ui.element("div").classes("pp-url-bar").style("flex:1").text = url + with ui.expansion("Request", value=True).classes("w-full"): if entry.query: - ui.label("Query Parameters").classes("text-caption text-weight-bold q-mt-sm") + ui.element("div").classes("pp-section-title").text = "QUERY" _render_query_params(entry.query) if entry.req_headers: - ui.label("Headers").classes("text-caption text-weight-bold q-mt-sm") + ui.element("div").classes("pp-section-title").style( + "margin-top:8px" + ).text = "HEADERS" _render_headers(entry.req_headers) if entry.req_body: - ui.label("Body").classes("text-caption text-weight-bold q-mt-sm") + ui.element("div").classes("pp-section-title").style("margin-top:8px").text = "BODY" ct = entry.req_headers.get("content-type", [""])[0] te = entry.req_headers.get("transfer-encoding", [""])[0] body = decode_chunked(entry.req_body) if "chunked" in te.lower() else entry.req_body @@ -75,18 +84,26 @@ def render_detail(entry: Entry | None, container: ui.element) -> None: ui.separator() # --- response --- - with ui.expansion("Response", icon="download", value=True).classes("w-full"): + with ui.expansion("Response", value=True).classes("w-full"): if entry.status_code: with ui.row().classes("items-center gap-2 q-mb-sm"): - status_badge(entry.status_code) - ui.label(f"{entry.duration_ms} ms").classes("text-caption text-grey") + ui.html( + f'{entry.status_code}' + ) + ui.element("span").style( + "font-size:11px; color:var(--pp-muted)" + ).text = f"{entry.duration_ms} ms" + if entry.resp_body: + ui.element("span").classes( + "pp-size-chip" + ).text = f"{len(entry.resp_body):,} B" if entry.resp_headers: - ui.label("Headers").classes("text-caption text-weight-bold q-mt-sm") + ui.element("div").classes("pp-section-title").text = "HEADERS" _render_headers(entry.resp_headers) if entry.resp_body: - ui.label("Body").classes("text-caption text-weight-bold q-mt-sm") + ui.element("div").classes("pp-section-title").style("margin-top:8px").text = "BODY" ct = entry.resp_headers.get("content-type", [""])[0] te = entry.resp_headers.get("transfer-encoding", [""])[0] body = ( @@ -95,10 +112,8 @@ def render_detail(entry: Entry | None, container: ui.element) -> None: charset = detect_charset(ct) _render_body_with_selector(body, ct, entry.resp_headers, charset) - ui.separator() - # --- replay --- - with ui.row().classes("q-pa-sm"): + with ui.element("div").style("padding:10px 14px"): ui.button("Replay", icon="replay", on_click=lambda: _replay(entry)).props( "color=primary size=sm" ) @@ -251,13 +266,11 @@ def _to_hex(raw: bytes, width: int = 16) -> str: def _render_headers(headers: dict[str, list[str]]) -> None: - with ui.element("table").classes("paxy-header-table w-full"): + with ui.element("div").classes("pp-headers"): for k, vs in sorted(headers.items()): - with ui.element("tr"): - ui.element("td").style( - "color:#aaa; min-width:160px; padding:2px 8px; font-size:12px" - ).text = k - ui.element("td").style("padding:2px 8px; font-size:12px").text = ", ".join(vs) + with ui.element("div").classes("pp-hrow"): + ui.element("div").classes("pp-hkey").text = k + ui.element("div").classes("pp-hval").text = ", ".join(vs) async def _replay(entry: Entry) -> None: diff --git a/pypproxy/ui/theme.py b/pypproxy/ui/theme.py index c5e8ef8..ff07b94 100644 --- a/pypproxy/ui/theme.py +++ b/pypproxy/ui/theme.py @@ -10,7 +10,15 @@ "OPTIONS": "grey", } -STATUS_COLORS: dict[int, str] = {} +PALETTE = { + "bg": "#0a0e1a", + "surface": "#111827", + "surface2": "#1a2236", + "border": "#1e2d45", + "accent": "#3b82f6", + "text": "#e2e8f0", + "text_muted": "#64748b", +} def status_color(code: int) -> str: @@ -37,23 +45,258 @@ def status_badge(code: int) -> None: def apply_dark_theme() -> None: - ui.add_head_html(""" + ui.add_head_html(f""" """) diff --git a/uv.lock b/uv.lock index 4a59260..565aea8 100644 --- a/uv.lock +++ b/uv.lock @@ -1608,7 +1608,7 @@ wheels = [ [[package]] name = "pypproxy" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From 084372e4f0cdaed73741262f8fb5740614df7530 Mon Sep 17 00:00:00 2001 From: yotti Date: Sat, 6 Jun 2026 23:06:18 +0900 Subject: [PATCH 2/4] ui: enlarge nav icons, add label text, widen sidebar to 220px --- pypproxy/ui/theme.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/pypproxy/ui/theme.py b/pypproxy/ui/theme.py index ff07b94..8583b23 100644 --- a/pypproxy/ui/theme.py +++ b/pypproxy/ui/theme.py @@ -67,7 +67,7 @@ def apply_dark_theme() -> None: /* ---- Sidebar ---- */ .pp-sidebar {{ - width: 200px; min-width: 200px; + width: 220px; min-width: 220px; background: var(--pp-surface); border-right: 1px solid var(--pp-border); display: flex; flex-direction: column; @@ -89,17 +89,18 @@ def apply_dark_theme() -> None: padding: 14px 0 4px; }} .pp-nav-section-label {{ - font-size: 9.5px; font-weight: 700; + font-size: 10px; font-weight: 700; color: var(--pp-muted); text-transform: uppercase; - letter-spacing: 0.1em; padding: 0 18px 5px; + letter-spacing: 0.08em; padding: 0 16px 5px; }} .pp-nav-item {{ - display: flex; align-items: center; gap: 9px; - padding: 7px 18px; cursor: pointer; - color: var(--pp-muted); font-size: 13px; + display: flex; align-items: center; gap: 10px; + padding: 8px 16px; cursor: pointer; + color: var(--pp-muted); font-size: 13.5px; font-weight: 400; border-left: 2px solid transparent; transition: all 0.12s; user-select: none; + white-space: nowrap; }} .pp-nav-item:hover {{ background: var(--pp-surface2); color: var(--pp-text); @@ -108,10 +109,14 @@ def apply_dark_theme() -> None: background: rgba(59,130,246,0.1); color: var(--pp-accent); border-left-color: var(--pp-accent); + font-weight: 500; }} .pp-nav-icon {{ - font-size: 15px; width: 16px; text-align: center; flex-shrink: 0; + font-size: 18px; width: 20px; text-align: center; flex-shrink: 0; + opacity: 0.75; }} + .pp-nav-item.active .pp-nav-icon {{ opacity: 1; }} + .pp-nav-item:hover .pp-nav-icon {{ opacity: 1; }} /* ---- Toolbar ---- */ .pp-toolbar {{ From 37dec5456d07da04a026a8dcd1fbbedb30cea167 Mon Sep 17 00:00:00 2001 From: yotti Date: Sat, 6 Jun 2026 23:08:43 +0900 Subject: [PATCH 3/4] ui: add light/dark mode toggle with CSS variable switching and localStorage persistence --- pypproxy/ui/app.py | 10 ++++++ pypproxy/ui/theme.py | 84 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/pypproxy/ui/app.py b/pypproxy/ui/app.py index f2dc49a..4f757d5 100644 --- a/pypproxy/ui/app.py +++ b/pypproxy/ui/app.py @@ -162,6 +162,16 @@ def _nav(pid=page_id, el=item_el) -> None: ui.button( icon="delete_sweep", on_click=lambda: _clear_traffic(store, state) ).props("flat dense size=sm color=negative").tooltip("Clear traffic") + ui.button( + icon="light_mode", + on_click=lambda: ui.run_javascript( + "const t = ppToggleTheme(); " + "document.querySelector('.pp-theme-btn .material-icons').textContent = " + "t === 'light' ? 'dark_mode' : 'light_mode';" + ), + ).props("flat dense size=sm").classes("pp-theme-btn").style( + f"color:{PALETTE['text_muted']}" + ).tooltip("Toggle light/dark mode") ui.button(icon="settings", on_click=lambda: ui.navigate.to("/settings")).props( "flat dense size=sm" ).style(f"color:{PALETTE['text_muted']}").tooltip("Settings") diff --git a/pypproxy/ui/theme.py b/pypproxy/ui/theme.py index 8583b23..1e47f0f 100644 --- a/pypproxy/ui/theme.py +++ b/pypproxy/ui/theme.py @@ -10,7 +10,7 @@ "OPTIONS": "grey", } -PALETTE = { +PALETTE_DARK = { "bg": "#0a0e1a", "surface": "#111827", "surface2": "#1a2236", @@ -20,6 +20,19 @@ "text_muted": "#64748b", } +PALETTE_LIGHT = { + "bg": "#f8fafc", + "surface": "#ffffff", + "surface2": "#f1f5f9", + "border": "#e2e8f0", + "accent": "#2563eb", + "text": "#0f172a", + "text_muted": "#94a3b8", +} + +# default +PALETTE = PALETTE_DARK + def status_color(code: int) -> str: if 200 <= code < 300: @@ -45,16 +58,27 @@ def status_badge(code: int) -> None: def apply_dark_theme() -> None: + dark = PALETTE_DARK + light = PALETTE_LIGHT ui.add_head_html(f""" + + """) From 5ce89003b5c4f2e5a703c98222f68c4cda3f79e9 Mon Sep 17 00:00:00 2001 From: yotti Date: Sat, 6 Jun 2026 23:11:27 +0900 Subject: [PATCH 4/4] fix: use ui.label() for sidebar nav labels (ui.element span.text was not rendering) --- pypproxy/ui/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypproxy/ui/app.py b/pypproxy/ui/app.py index 4f757d5..dadd92a 100644 --- a/pypproxy/ui/app.py +++ b/pypproxy/ui/app.py @@ -105,7 +105,7 @@ async def index() -> None: item_el = ui.element("div").classes("pp-nav-item") with item_el: ui.html(f'{icon}') - ui.element("span").text = label + ui.label(label).style("font-size:13.5px") def _nav(pid=page_id, el=item_el) -> None: state["active_page"] = pid @@ -130,7 +130,7 @@ def _nav(pid=page_id, el=item_el) -> None: ui.html( 'settings' ) - ui.element("span").style("font-size:12px").text = "Settings" + ui.label("Settings").style("font-size:12px") settings_item.on("click", lambda: ui.navigate.to("/settings")) # ── Main area ─────────────────────────────────────────